From 619ee7eac90d54be62b40db3c4435a6adb74742d Mon Sep 17 00:00:00 2001 From: eryue0220 Date: Wed, 8 Jul 2026 01:39:17 +0800 Subject: [PATCH 1/6] update jira service and doc --- .../versions/094_add_jira_credential_type.py | 24 + backend/app/api/credentials.py | 57 + backend/app/db/models.py | 1 + backend/app/models/schemas.py | 8 + backend/app/services/jira_service.py | 440 +++++++ .../node_execution/nodes/jira_node.py | 452 +++++++ .../app/services/node_execution/registry.py | 1 + backend/app/services/workflow_dsl_prompt.py | 83 +- backend/tests/test_alembic_migrations.py | 8 +- backend/tests/test_jira_node.py | 1063 +++++++++++++++++ frontend/e2e/jira-node-properties.spec.ts | 198 +++ .../src/components/Canvas/WorkflowCanvas.vue | 1 + .../Credentials/CredentialDialog.vue | 168 ++- .../Credentials/CredentialsPanel.vue | 3 + frontend/src/components/Nodes/BaseNode.vue | 2 + frontend/src/components/Panels/NodePanel.vue | 1 + .../nodes/JiraNodeProperties.vue | 749 ++++++++++++ .../nodes/NodePropertiesForm.vue | 2 + .../propertiesPanel/operationOptions.ts | 90 ++ .../usePropertiesPanelController.ts | 342 ++++++ frontend/src/docs/content/nodes/jira-node.md | 199 +++ .../content/reference/credentials-sharing.md | 3 +- .../src/docs/content/reference/credentials.md | 6 +- .../src/docs/content/reference/features.md | 12 +- .../docs/content/reference/integrations.md | 36 + .../src/docs/content/reference/node-types.md | 1 + frontend/src/docs/manifest.ts | 1 + frontend/src/lib/jiraExpressionFields.test.ts | 171 +++ frontend/src/lib/jiraExpressionFields.ts | 121 ++ frontend/src/lib/nodeIcons.ts | 2 + frontend/src/stores/workflow.ts | 157 +++ frontend/src/styles/globals.css | 2 + frontend/src/types/credential.ts | 11 + frontend/src/types/node.ts | 43 + frontend/src/types/workflow.ts | 54 + 35 files changed, 4504 insertions(+), 8 deletions(-) create mode 100644 backend/alembic/versions/094_add_jira_credential_type.py create mode 100644 backend/app/services/jira_service.py create mode 100644 backend/app/services/node_execution/nodes/jira_node.py create mode 100644 backend/tests/test_jira_node.py create mode 100644 frontend/e2e/jira-node-properties.spec.ts create mode 100644 frontend/src/components/Panels/propertiesPanel/nodes/JiraNodeProperties.vue create mode 100644 frontend/src/docs/content/nodes/jira-node.md create mode 100644 frontend/src/lib/jiraExpressionFields.test.ts create mode 100644 frontend/src/lib/jiraExpressionFields.ts diff --git a/backend/alembic/versions/094_add_jira_credential_type.py b/backend/alembic/versions/094_add_jira_credential_type.py new file mode 100644 index 00000000..a79cc833 --- /dev/null +++ b/backend/alembic/versions/094_add_jira_credential_type.py @@ -0,0 +1,24 @@ +"""add jira credential type + +Revision ID: 094_add_jira_credential_type +Revises: 093_add_codex_node_support +Create Date: 2026-07-06 00:00:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "094_add_jira_credential_type" +down_revision: str | Sequence[str] | None = "093_add_codex_node_support" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TYPE credential_type ADD VALUE IF NOT EXISTS 'jira'") + + +def downgrade() -> None: + # PostgreSQL enum values cannot be removed safely without rebuilding the type. + pass diff --git a/backend/app/api/credentials.py b/backend/app/api/credentials.py index 4b5d93ff..a91935cc 100644 --- a/backend/app/api/credentials.py +++ b/backend/app/api/credentials.py @@ -124,6 +124,14 @@ def merge_credential_config_for_update( return merged_config + if credential_type == CredentialType.jira: + merged_config = dict(existing_config) + for key in ("email", "api_token", "base_url", "api_version"): + incoming_value = str(incoming_config.get(key, "") or "").strip() + if incoming_value: + merged_config[key] = incoming_value + return merged_config + if credential_type != CredentialType.github: return incoming_config @@ -181,6 +189,9 @@ def get_masked_value(credential_type: CredentialType, config: dict) -> str | Non ): api_key = config.get("api_key", "") return mask_api_key(api_key) + elif credential_type == CredentialType.jira: + api_token = str(config.get("api_token", "")).strip() + return mask_api_key(api_token) elif credential_type == CredentialType.qdrant: openai_api_key = config.get("openai_api_key", "") return mask_api_key(openai_api_key) @@ -274,6 +285,11 @@ def get_public_credential_fields( if credential_type == CredentialType.linear: auth_mode = str(config.get("auth_mode", "api_key")).strip() or "api_key" return {"auth_mode": auth_mode} + if credential_type == CredentialType.jira: + base_url = str(config.get("base_url", "")).strip() or None + email = str(config.get("email", "")).strip() or None + api_version = str(config.get("api_version", "") or "").strip() or None + return {"base_url": base_url, "email": email, "api_version": api_version} if credential_type == CredentialType.codex: auth_mode = str(config.get("auth_mode", "access_token")).strip() or "access_token" fields: dict[str, str | None] = {"auth_mode": auth_mode} @@ -799,6 +815,7 @@ async def run_credential_connection_test( """Test whether a credential configuration can reach the external service.""" if test_data.type not in { CredentialType.supabase, + CredentialType.jira, CredentialType.linear, CredentialType.notion, CredentialType.clickhouse, @@ -827,6 +844,8 @@ async def run_credential_connection_test( config = _merge_supabase_test_config(config, stored_config) elif test_data.type == CredentialType.linear: config = _merge_linear_test_config(config, stored_config) + elif test_data.type == CredentialType.jira: + config = {**stored_config, **{k: v for k, v in config.items() if v}} elif test_data.type == CredentialType.clickhouse: config = _merge_clickhouse_test_config(config, stored_config) elif test_data.type == CredentialType.sentry: @@ -861,6 +880,21 @@ async def run_credential_connection_test( ) return CredentialTestResponse(success=True, message="Connection successful") + if test_data.type == CredentialType.jira: + from app.services.jira_service import JiraService + + service = JiraService(config) + try: + user = service.test_connection() + finally: + service.close() + user_name = str( + user.get("displayName") or user.get("emailAddress") or user.get("accountId") or "" + ) + if user_name: + return CredentialTestResponse(success=True, message=f"Connected as {user_name}") + return CredentialTestResponse(success=True, message="Connection successful") + if test_data.type == CredentialType.clickhouse: from app.services.clickhouse_service import ClickHouseService @@ -1332,6 +1366,29 @@ def validate_credential_config( status_code=status.HTTP_400_BAD_REQUEST, detail="GitHub credential base_url must be a valid http(s) URL", ) + elif credential_type == CredentialType.jira: + if "email" not in config or not str(config["email"]).strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Jira credential requires email", + ) + if "api_token" not in config or not str(config["api_token"]).strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Jira credential requires api_token", + ) + jira_base_url = str(config.get("base_url", "") or "").strip() + if not jira_base_url: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Jira credential requires base_url", + ) + parsed = urlparse(jira_base_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Jira credential base_url must be a valid http(s) URL", + ) elif credential_type == CredentialType.sentry: if "api_token" not in config or not str(config["api_token"]).strip(): raise HTTPException( diff --git a/backend/app/db/models.py b/backend/app/db/models.py index de9d27f8..be5ba1ec 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -30,6 +30,7 @@ class CredentialType(str, PyEnum): codex = "codex" google = "google" github = "github" + jira = "jira" linear = "linear" custom = "custom" bearer = "bearer" diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 7916549f..bbaef92c 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -472,6 +472,7 @@ class CredentialType(str, Enum): codex = "codex" google = "google" github = "github" + jira = "jira" linear = "linear" custom = "custom" bearer = "bearer" @@ -517,6 +518,13 @@ class CredentialConfigGitHub(BaseModel): base_url: str | None = None +class CredentialConfigJira(BaseModel): + email: str + api_token: str + base_url: str + api_version: str | None = None + + class CredentialConfigLinear(BaseModel): api_key: str | None = None auth_mode: str | None = None diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py new file mode 100644 index 00000000..2f9e76c7 --- /dev/null +++ b/backend/app/services/jira_service.py @@ -0,0 +1,440 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import urljoin + +import httpx + +from app.http_identity import merge_outbound_headers + +_DEFAULT_API_VERSION = "3" +_DEFAULT_SEARCH_FIELDS = ["key", "summary", "status", "assignee", "issuetype"] +_DEFAULT_SEARCH_JQL = "updated >= -30d ORDER BY updated DESC" +_MAX_ERROR_DETAIL_CHARS = 500 +_UNSET = object() + + +class JiraService: + """Small REST client for common Jira project, issue, comment, attachment, and user operations.""" + + def __init__(self, config: dict[str, Any], client: httpx.Client | None = None) -> None: + self._config = dict(config) + self._base_url = self._normalize_base_url(str(self._config.get("base_url", "") or "")) + self._api_version = str(self._config.get("api_version") or _DEFAULT_API_VERSION).strip() + self._client = client or httpx.Client( + headers=merge_outbound_headers({"Accept": "application/json"}), + timeout=httpx.Timeout(30.0), + follow_redirects=True, + ) + self._owns_client = client is None + + def close(self) -> None: + """Close the internally owned HTTP client, if any.""" + if self._owns_client and not self._client.is_closed: + self._client.close() + + def test_connection(self) -> dict[str, Any]: + """Verify the credential by fetching the current Jira user.""" + return self.get_myself() + + def get_myself(self) -> dict[str, Any]: + """Return the authenticated Jira user.""" + return self._expect_object(self._request("GET", "/myself"), "myself") + + def list_projects(self, limit: int = 50, start_at: int = 0) -> dict[str, Any]: + """List Jira projects visible to the authenticated user.""" + payload = self._request( + "GET", + "/project/search", + params={"maxResults": self._normalize_limit(limit), "startAt": max(start_at, 0)}, + ) + return self._expect_object(payload, "project.search") + + def search_issues( + self, + jql: str, + limit: int = 50, + *, + next_page_token: str | None = None, + fields: list[str] | None = None, + ) -> dict[str, Any]: + """Search issues with JQL via the current /search/jql endpoint.""" + body: dict[str, Any] = { + "jql": jql or _DEFAULT_SEARCH_JQL, + "maxResults": self._normalize_limit(limit), + "fields": fields or list(_DEFAULT_SEARCH_FIELDS), + } + if next_page_token: + body["nextPageToken"] = next_page_token + payload = self._request("POST", "/search/jql", json=body) + return self._expect_object(payload, "issue.search") + + def get_issue(self, issue_key: str) -> dict[str, Any]: + """Fetch one Jira issue by key or ID.""" + payload = self._request("GET", f"/issue/{issue_key}") + return self._expect_object(payload, "issue") + + def create_issue( + self, + project_key: str, + issue_type: str, + summary: str, + description: str | None = None, + assignee_account_id: str | None = None, + labels: list[str] | None = None, + issue_type_id: str | None = None, + ) -> dict[str, Any]: + """Create a Jira issue.""" + issuetype: dict[str, str] + if issue_type_id: + issuetype = {"id": issue_type_id} + else: + issuetype = {"name": issue_type} + fields: dict[str, Any] = { + "project": {"key": project_key}, + "issuetype": issuetype, + "summary": summary, + } + if description: + fields["description"] = self._adf_text_document(description) + if assignee_account_id: + fields["assignee"] = {"accountId": assignee_account_id} + if labels: + fields["labels"] = labels + return self._expect_object( + self._request("POST", "/issue", json={"fields": fields}), "issue" + ) + + def update_issue( + self, + issue_key: str, + *, + summary: Any = _UNSET, + description: Any = _UNSET, + assignee_account_id: Any = _UNSET, + labels: Any = _UNSET, + ) -> dict[str, Any]: + """Update fields on an existing Jira issue.""" + fields: dict[str, Any] = {} + if summary is not _UNSET: + fields["summary"] = summary + if description is not _UNSET: + fields["description"] = ( + None if description is None else self._adf_text_document(description) + ) + if assignee_account_id is not _UNSET: + fields["assignee"] = ( + None if assignee_account_id is None else {"accountId": assignee_account_id} + ) + if labels is not _UNSET: + fields["labels"] = labels + if not fields: + raise ValueError("Jira updateIssue requires at least one field to update") + self._request("PUT", f"/issue/{issue_key}", json={"fields": fields}, expect_json=False) + return self.get_issue(issue_key) + + def delete_issue(self, issue_key: str) -> bool: + """Delete a Jira issue.""" + self._request("DELETE", f"/issue/{issue_key}", expect_json=False) + return True + + def get_issue_changelog( + self, issue_key: str, limit: int = 50, start_at: int = 0 + ) -> dict[str, Any]: + """Return the changelog entries for a Jira issue.""" + payload = self._request( + "GET", + f"/issue/{issue_key}/changelog", + params={"maxResults": self._normalize_limit(limit), "startAt": max(start_at, 0)}, + ) + return self._expect_object(payload, "issue.changelog") + + def notify_issue( + self, + issue_key: str, + *, + subject: str, + text_body: str, + html_body: str | None = None, + to: dict[str, Any] | None = None, + ) -> bool: + """Send a Jira issue notification.""" + payload: dict[str, Any] = { + "subject": subject, + "textBody": text_body, + "to": to or {"assignee": True}, + } + if html_body: + payload["htmlBody"] = html_body + self._request("POST", f"/issue/{issue_key}/notify", json=payload, expect_json=False) + return True + + def list_comments(self, issue_key: str, limit: int = 50, start_at: int = 0) -> dict[str, Any]: + """List comments on a Jira issue.""" + payload = self._request( + "GET", + f"/issue/{issue_key}/comment", + params={"maxResults": self._normalize_limit(limit), "startAt": max(start_at, 0)}, + ) + return self._expect_object(payload, "issue.comments") + + def create_comment(self, issue_key: str, body: str) -> dict[str, Any]: + """Add a comment to a Jira issue.""" + payload = self._request( + "POST", + f"/issue/{issue_key}/comment", + json={"body": self._adf_text_document(body)}, + ) + return self._expect_object(payload, "comment") + + def get_comment(self, issue_key: str, comment_id: str) -> dict[str, Any]: + """Fetch one comment on a Jira issue.""" + payload = self._request("GET", f"/issue/{issue_key}/comment/{comment_id}") + return self._expect_object(payload, "comment") + + def update_comment(self, issue_key: str, comment_id: str, body: str) -> dict[str, Any]: + """Update a comment on a Jira issue.""" + payload = self._request( + "PUT", + f"/issue/{issue_key}/comment/{comment_id}", + json={"body": self._adf_text_document(body)}, + ) + return self._expect_object(payload, "comment") + + def delete_comment(self, issue_key: str, comment_id: str) -> bool: + """Delete a comment from a Jira issue.""" + self._request("DELETE", f"/issue/{issue_key}/comment/{comment_id}", expect_json=False) + return True + + def list_transitions(self, issue_key: str) -> list[dict[str, Any]]: + """List available transitions for a Jira issue.""" + payload = self._expect_object( + self._request("GET", f"/issue/{issue_key}/transitions"), + "issue.transitions", + ) + transitions = payload.get("transitions") + if not isinstance(transitions, list): + raise ValueError("Jira API returned an invalid transitions payload") + return [transition for transition in transitions if isinstance(transition, dict)] + + def transition_issue(self, issue_key: str, transition_id: str) -> dict[str, Any]: + """Transition a Jira issue using a transition ID.""" + self._request( + "POST", + f"/issue/{issue_key}/transitions", + json={"transition": {"id": transition_id}}, + expect_json=False, + ) + issue = self.get_issue(issue_key) + return {"transitionId": transition_id, "issue": issue} + + def add_attachment( + self, + issue_key: str, + *, + filename: str, + content: bytes, + mime_type: str | None = None, + ) -> list[dict[str, Any]]: + """Add a file attachment to a Jira issue.""" + payload = self._request( + "POST", + f"/issue/{issue_key}/attachments", + files={"file": (filename, content, mime_type or "application/octet-stream")}, + headers={"X-Atlassian-Token": "no-check"}, + ) + if not isinstance(payload, list): + raise ValueError("Jira API returned an invalid attachment upload payload") + return [attachment for attachment in payload if isinstance(attachment, dict)] + + def get_attachment(self, attachment_id: str) -> dict[str, Any]: + """Fetch one Jira attachment metadata object.""" + payload = self._request("GET", f"/attachment/{attachment_id}") + return self._expect_object(payload, "attachment") + + def list_attachments( + self, issue_key: str, limit: int = 50, start_at: int = 0 + ) -> dict[str, Any]: + """List attachment metadata for a Jira issue.""" + issue = self.get_issue_with_fields(issue_key, ["attachment"]) + fields = issue.get("fields") + attachments = fields.get("attachment") if isinstance(fields, dict) else [] + if not isinstance(attachments, list): + raise ValueError("Jira API returned an invalid issue attachment payload") + normalized = [attachment for attachment in attachments if isinstance(attachment, dict)] + normalized_limit = self._normalize_limit(limit) + offset = max(start_at, 0) + page = normalized[offset : offset + normalized_limit] + total = len(normalized) + return { + "attachments": page, + "startAt": offset, + "maxResults": normalized_limit, + "total": total, + "isLast": offset + normalized_limit >= total, + } + + def delete_attachment(self, attachment_id: str) -> bool: + """Delete a Jira attachment by ID.""" + self._request("DELETE", f"/attachment/{attachment_id}", expect_json=False) + return True + + def download_attachment(self, content_url: str) -> bytes: + """Download raw attachment content from Jira's content URL.""" + payload = self._request_absolute( + "GET", + content_url, + headers={"Accept": "*/*"}, + expect_json=False, + ) + if not isinstance(payload, bytes): + raise ValueError("Jira API returned invalid attachment content") + return payload + + def get_user(self, account_id: str) -> dict[str, Any]: + """Fetch a Jira user by account ID.""" + payload = self._request("GET", "/user", params={"accountId": account_id}) + return self._expect_object(payload, "user") + + def create_user( + self, + email_address: str, + *, + display_name: str | None = None, + products: list[str] | None = None, + ) -> dict[str, Any]: + """Create a Jira user.""" + payload: dict[str, Any] = {"emailAddress": email_address} + if display_name: + payload["displayName"] = display_name + if products: + payload["products"] = products + result = self._request("POST", "/user", json=payload) + return self._expect_object(result, "user") + + def delete_user(self, account_id: str) -> bool: + """Delete a Jira user by account ID.""" + self._request("DELETE", "/user", params={"accountId": account_id}, expect_json=False) + return True + + def get_issue_with_fields(self, issue_key: str, fields: list[str]) -> dict[str, Any]: + """Fetch one Jira issue with a restricted fields list.""" + payload = self._request( + "GET", + f"/issue/{issue_key}", + params={"fields": ",".join(fields)}, + ) + return self._expect_object(payload, "issue") + + def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + expect_json: bool = True, + ) -> Any: + return self._request_absolute( + method, + self._url(path), + params=params, + json=json, + files=files, + headers=headers, + expect_json=expect_json, + ) + + def _request_absolute( + self, + method: str, + url: str, + *, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + files: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + expect_json: bool = True, + ) -> Any: + email = str(self._config.get("email", "") or "").strip() + api_token = str( + self._config.get("api_token", "") or self._config.get("api_key", "") or "" + ).strip() + if not email or not api_token: + raise ValueError("Jira credential requires email and api_token") + request_headers = {"Accept": "application/json"} + if files is None: + request_headers["Content-Type"] = "application/json" + if headers: + request_headers.update(headers) + try: + response = self._client.request( + method, + url, + params=params, + json=json, + files=files, + auth=(email, api_token), + headers=request_headers, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = self._truncate_error_detail(response.text) + raise ValueError( + f"Jira API request failed with status {response.status_code}: {detail}" + ) from exc + except httpx.RequestError as exc: + raise ValueError(f"Jira API request failed: {exc}") from exc + if not expect_json: + return response.content + if response.status_code == 204 or not response.content: + return {} + try: + payload = response.json() + except ValueError as exc: + raise ValueError("Jira API returned non-JSON response") from exc + return payload + + def _url(self, path: str) -> str: + return urljoin(f"{self._base_url}/rest/api/{self._api_version}/", path.lstrip("/")) + + @staticmethod + def _normalize_base_url(base_url: str) -> str: + normalized = base_url.strip().rstrip("/") + if not normalized: + raise ValueError("Jira credential requires base_url") + return normalized + + @staticmethod + def _normalize_limit(limit: int) -> int: + return max(1, min(int(limit), 100)) + + @staticmethod + def _expect_object(payload: Any, label: str) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ValueError(f"Jira API returned an invalid {label} payload") + return payload + + @staticmethod + def _adf_text_document(text: str) -> dict[str, Any]: + lines = text.split("\n") + content: list[dict[str, Any]] = [] + for line in lines: + paragraph: dict[str, Any] = {"type": "paragraph"} + if line: + paragraph["content"] = [{"type": "text", "text": line}] + else: + paragraph["content"] = [] + content.append(paragraph) + if not content: + content = [{"type": "paragraph", "content": []}] + return {"type": "doc", "version": 1, "content": content} + + @staticmethod + def _truncate_error_detail(detail: str) -> str: + normalized = detail.strip() + if len(normalized) <= _MAX_ERROR_DETAIL_CHARS: + return normalized + return f"{normalized[:_MAX_ERROR_DETAIL_CHARS]}..." diff --git a/backend/app/services/node_execution/nodes/jira_node.py b/backend/app/services/node_execution/nodes/jira_node.py new file mode 100644 index 00000000..9c5206d9 --- /dev/null +++ b/backend/app/services/node_execution/nodes/jira_node.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +import json +from base64 import b64decode, b64encode +from importlib import import_module +from mimetypes import guess_type +from typing import Any + +from app.services.jira_service import _DEFAULT_SEARCH_JQL, _UNSET +from app.services.node_execution.base import NodeExecutionContext + + +def execute(ctx: NodeExecutionContext) -> object: + """Execute the Jira node.""" + _workflow_executor = import_module("app.services.workflow_executor") + _coerce_boolean = _workflow_executor._coerce_boolean + self = ctx.executor + node_id = ctx.node_id + inputs = ctx.inputs + node_data = ctx.node_data + + from app.config import settings + from app.db.models import CredentialType + from app.db.session import SessionLocal + from app.services.encryption import decrypt_config + from app.services.jira_service import JiraService + + credential_id = node_data.get("credentialId") + if not credential_id: + raise ValueError("Jira node requires a credential") + + jira_config: dict[str, Any] = {} + with SessionLocal() as db: + cred = self._get_accessible_credential(db, credential_id) + if cred: + if cred.type != CredentialType.jira: + raise ValueError("Jira node requires a Jira credential") + jira_config = decrypt_config(cred.encrypted_config) + if not jira_config: + raise ValueError("Jira credential not found or invalid") + + operation = str(node_data.get("jiraOperation", "") or "").strip() + if not operation: + raise ValueError("Jira node requires an operation") + + def _field(name: str, default: str = "") -> str: + raw_value = node_data.get(name, default) + if raw_value is None or str(raw_value).strip() == "": + return default + return self.evaluate_message_template(str(raw_value), inputs, node_id).strip() + + def _limit() -> int: + try: + return max(1, min(int(float(_field("jiraLimit", "50") or "50")), 100)) + except (TypeError, ValueError): + return 50 + + def _start_at() -> int: + try: + return max(0, int(float(_field("jiraStartAt", "0") or "0"))) + except (TypeError, ValueError): + return 0 + + def _next_page_token() -> str | None: + token = _field("jiraNextPageToken") + return token or None + + def _search_fields() -> list[str] | None: + return _string_list_field("jiraFields") + + def _labels() -> list[str] | None: + raw_labels = _field("jiraLabels") + if not raw_labels: + return None + try: + parsed = json.loads(raw_labels) + except json.JSONDecodeError: + return [label.strip() for label in raw_labels.split(",") if label.strip()] + if not isinstance(parsed, list) or not all(isinstance(label, str) for label in parsed): + raise ValueError("Jira labels must be a JSON array of strings or comma-separated text") + return parsed + + def _json_object_field(name: str, default: dict[str, Any] | None = None) -> dict[str, Any]: + raw_value = _field(name) + if not raw_value: + return default or {} + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError as exc: + raise ValueError(f"Jira {name} must be a JSON object") from exc + if not isinstance(parsed, dict): + raise ValueError(f"Jira {name} must be a JSON object") + return parsed + + def _string_list_field(name: str) -> list[str] | None: + raw_value = _field(name) + if not raw_value: + return None + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError: + return [item.strip() for item in raw_value.split(",") if item.strip()] + if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed): + raise ValueError(f"Jira {name} must be a JSON array of strings or comma-separated text") + return parsed + + def _attachment_content() -> bytes: + base64_content = _field("jiraAttachmentBase64") + if not base64_content: + raise ValueError("Jira addAttachment requires base64 content") + base64_payload = base64_content + if base64_payload.startswith("data:"): + comma_index = base64_payload.find(",") + if comma_index == -1: + raise ValueError("Jira attachment content must be valid base64 or a data URL") + base64_payload = base64_payload[comma_index + 1 :].strip() + try: + content = b64decode(base64_payload, validate=True) + except Exception as exc: + raise ValueError("Jira attachment content must be valid base64") from exc + max_bytes = settings.file_max_size_mb * 1024 * 1024 + if len(content) > max_bytes: + raise ValueError(f"Jira attachment exceeds size limit ({settings.file_max_size_mb} MB)") + return content + + def _with_optional_binary(attachment: dict[str, Any]) -> dict[str, Any]: + output = dict(attachment) + if not _coerce_boolean(node_data.get("jiraIncludeBinary"), default=False): + return output + content_url = str(attachment.get("content") or "").strip() + if not content_url: + raise ValueError("Jira attachment metadata does not include a content URL") + content = service.download_attachment(content_url) + output["content_base64"] = b64encode(content).decode("ascii") + return output + + def _update_field(name: str) -> Any: + if name not in node_data: + return _UNSET + raw_value = node_data.get(name) + if raw_value is None or str(raw_value).strip() == "": + return _UNSET + resolved = self.evaluate_message_template(str(raw_value), inputs, node_id).strip() + if resolved.lower() == "null": + return None + return resolved + + def _update_labels() -> Any: + if "jiraLabels" not in node_data: + return _UNSET + raw_value = node_data.get("jiraLabels") + if raw_value is None or str(raw_value).strip() == "": + return _UNSET + return _labels() + + service = JiraService(jira_config) + try: + if operation == "getMyself": + output = {"success": True, "operation": operation, "user": service.get_myself()} + elif operation == "listProjects": + result = service.list_projects(_limit(), _start_at()) + values = result.get("values") if isinstance(result.get("values"), list) else [] + output = { + "success": True, + "operation": operation, + "projects": values, + "count": len(values), + "pagination": _pagination(result), + } + elif operation == "searchIssues": + result = service.search_issues( + _field("jiraJql", _DEFAULT_SEARCH_JQL), + _limit(), + next_page_token=_next_page_token(), + fields=_search_fields(), + ) + issues = result.get("issues") if isinstance(result.get("issues"), list) else [] + output = { + "success": True, + "operation": operation, + "issues": issues, + "count": len(issues), + "pagination": _search_pagination(result, _limit()), + } + elif operation == "getIssue": + issue_key = _field("jiraIssueKey") + if not issue_key: + raise ValueError("Jira getIssue requires an issue key or ID") + issue = service.get_issue(issue_key) + output = { + "success": True, + "operation": operation, + "issue": issue, + "key": issue.get("key"), + } + elif operation == "createIssue": + project_key = _field("jiraProjectKey") + summary = _field("jiraSummary") + if not project_key or not summary: + raise ValueError("Jira createIssue requires project key and summary") + issue = service.create_issue( + project_key, + _field("jiraIssueType", "Task") or "Task", + summary, + description=_field("jiraDescription") or None, + assignee_account_id=_field("jiraAssigneeAccountId") or None, + labels=_labels(), + issue_type_id=_field("jiraIssueTypeId") or None, + ) + output = { + "success": True, + "operation": operation, + "issue": issue, + "key": issue.get("key"), + } + elif operation == "updateIssue": + issue_key = _field("jiraIssueKey") + if not issue_key: + raise ValueError("Jira updateIssue requires an issue key or ID") + issue = service.update_issue( + issue_key, + summary=_update_field("jiraSummary"), + description=_update_field("jiraDescription"), + assignee_account_id=_update_field("jiraAssigneeAccountId"), + labels=_update_labels(), + ) + output = { + "success": True, + "operation": operation, + "issue": issue, + "key": issue.get("key"), + } + elif operation == "deleteIssue": + issue_key = _field("jiraIssueKey") + if not issue_key: + raise ValueError("Jira deleteIssue requires an issue key or ID") + output = { + "success": True, + "operation": operation, + "deleted": service.delete_issue(issue_key), + } + elif operation == "getIssueChangelog": + issue_key = _field("jiraIssueKey") + if not issue_key: + raise ValueError("Jira getIssueChangelog requires an issue key or ID") + result = service.get_issue_changelog(issue_key, _limit(), _start_at()) + histories = result.get("histories") if isinstance(result.get("histories"), list) else [] + output = { + "success": True, + "operation": operation, + "changelog": histories, + "count": len(histories), + "pagination": _pagination(result), + } + elif operation == "notifyIssue": + issue_key = _field("jiraIssueKey") + subject = _field("jiraNotifySubject") + text_body = _field("jiraNotifyTextBody") + if not issue_key or not subject or not text_body: + raise ValueError("Jira notifyIssue requires issue key, subject, and text body") + output = { + "success": True, + "operation": operation, + "notified": service.notify_issue( + issue_key, + subject=subject, + text_body=text_body, + html_body=_field("jiraNotifyHtmlBody") or None, + to=_json_object_field("jiraNotifyTo", {"assignee": True}), + ), + } + elif operation == "listComments": + issue_key = _field("jiraIssueKey") + if not issue_key: + raise ValueError("Jira listComments requires an issue key or ID") + result = service.list_comments(issue_key, _limit(), _start_at()) + comments = result.get("comments") if isinstance(result.get("comments"), list) else [] + output = { + "success": True, + "operation": operation, + "comments": comments, + "count": len(comments), + "pagination": _pagination(result), + } + elif operation == "createComment": + issue_key = _field("jiraIssueKey") + body = _field("jiraCommentBody") + if not issue_key or not body: + raise ValueError("Jira createComment requires an issue key and comment body") + output = { + "success": True, + "operation": operation, + "comment": service.create_comment(issue_key, body), + } + elif operation == "getComment": + issue_key = _field("jiraIssueKey") + comment_id = _field("jiraCommentId") + if not issue_key or not comment_id: + raise ValueError("Jira getComment requires an issue key and comment ID") + output = { + "success": True, + "operation": operation, + "comment": service.get_comment(issue_key, comment_id), + } + elif operation == "updateComment": + issue_key = _field("jiraIssueKey") + comment_id = _field("jiraCommentId") + body = _field("jiraCommentBody") + if not issue_key or not comment_id or not body: + raise ValueError("Jira updateComment requires an issue key, comment ID, and body") + output = { + "success": True, + "operation": operation, + "comment": service.update_comment(issue_key, comment_id, body), + } + elif operation == "deleteComment": + issue_key = _field("jiraIssueKey") + comment_id = _field("jiraCommentId") + if not issue_key or not comment_id: + raise ValueError("Jira deleteComment requires an issue key and comment ID") + output = { + "success": True, + "operation": operation, + "deleted": service.delete_comment(issue_key, comment_id), + } + elif operation == "listTransitions": + issue_key = _field("jiraIssueKey") + if not issue_key: + raise ValueError("Jira listTransitions requires an issue key or ID") + transitions = service.list_transitions(issue_key) + output = { + "success": True, + "operation": operation, + "transitions": transitions, + "count": len(transitions), + } + elif operation == "transitionIssue": + issue_key = _field("jiraIssueKey") + transition_id = _field("jiraTransitionId") + if not issue_key or not transition_id: + raise ValueError("Jira transitionIssue requires an issue key and transition ID") + transition = service.transition_issue(issue_key, transition_id) + issue = transition.get("issue") if isinstance(transition.get("issue"), dict) else {} + output = { + "success": True, + "operation": operation, + "transition": {"transitionId": transition.get("transitionId")}, + "issue": issue, + "key": issue.get("key"), + } + elif operation == "addAttachment": + issue_key = _field("jiraIssueKey") + filename = _field("jiraAttachmentFilename") + if not issue_key or not filename: + raise ValueError("Jira addAttachment requires an issue key and filename") + mime_type = _field("jiraAttachmentMimeType") or guess_type(filename)[0] + attachments = service.add_attachment( + issue_key, + filename=filename, + content=_attachment_content(), + mime_type=mime_type, + ) + output = { + "success": True, + "operation": operation, + "attachments": attachments, + "count": len(attachments), + } + elif operation == "getAttachment": + attachment_id = _field("jiraAttachmentId") + if not attachment_id: + raise ValueError("Jira getAttachment requires an attachment ID") + output = { + "success": True, + "operation": operation, + "attachment": _with_optional_binary(service.get_attachment(attachment_id)), + } + elif operation == "listAttachments": + issue_key = _field("jiraIssueKey") + if not issue_key: + raise ValueError("Jira listAttachments requires an issue key or ID") + result = service.list_attachments(issue_key, _limit(), _start_at()) + attachments = [ + _with_optional_binary(attachment) + for attachment in result.get("attachments", []) + if isinstance(attachment, dict) + ] + output = { + "success": True, + "operation": operation, + "attachments": attachments, + "count": len(attachments), + "pagination": _pagination(result), + } + elif operation == "deleteAttachment": + attachment_id = _field("jiraAttachmentId") + if not attachment_id: + raise ValueError("Jira deleteAttachment requires an attachment ID") + output = { + "success": True, + "operation": operation, + "deleted": service.delete_attachment(attachment_id), + } + elif operation == "getUser": + account_id = _field("jiraAccountId") + if not account_id: + raise ValueError("Jira getUser requires an account ID") + output = {"success": True, "operation": operation, "user": service.get_user(account_id)} + elif operation == "createUser": + email_address = _field("jiraUserEmail") + if not email_address: + raise ValueError("Jira createUser requires an email address") + output = { + "success": True, + "operation": operation, + "user": service.create_user( + email_address, + display_name=_field("jiraUserDisplayName") or None, + products=_string_list_field("jiraUserProducts"), + ), + } + elif operation == "deleteUser": + account_id = _field("jiraAccountId") + if not account_id: + raise ValueError("Jira deleteUser requires an account ID") + output = { + "success": True, + "operation": operation, + "deleted": service.delete_user(account_id), + } + else: + raise ValueError(f"Unknown Jira operation: {operation}") + finally: + service.close() + return output + + +def _pagination(result: dict[str, Any]) -> dict[str, Any]: + return { + "startAt": result.get("startAt"), + "maxResults": result.get("maxResults"), + "total": result.get("total"), + "isLast": result.get("isLast"), + } + + +def _search_pagination(result: dict[str, Any], limit: int) -> dict[str, Any]: + return { + "maxResults": limit, + "nextPageToken": result.get("nextPageToken"), + "isLast": result.get("isLast"), + } diff --git a/backend/app/services/node_execution/registry.py b/backend/app/services/node_execution/registry.py index 67a1971b..98fae1d5 100644 --- a/backend/app/services/node_execution/registry.py +++ b/backend/app/services/node_execution/registry.py @@ -32,6 +32,7 @@ "http": "http_node", "imapTrigger": "imap_trigger_node", "jsonOutputMapper": "json_output_mapper_node", + "jira": "jira_node", "linear": "linear_node", "llm": "llm_node", "loop": "loop_node", diff --git a/backend/app/services/workflow_dsl_prompt.py b/backend/app/services/workflow_dsl_prompt.py index a5e9112d..7046433b 100644 --- a/backend/app/services/workflow_dsl_prompt.py +++ b/backend/app/services/workflow_dsl_prompt.py @@ -4003,6 +4003,87 @@ } ``` +### 35. jira (Jira REST Operations) +- **Type**: `jira` +- **Purpose**: Manage Jira projects, issues, comments, attachments, users, notifications, and workflow transitions +- **Inputs**: 1 | **Outputs**: 1 +- **Required setup**: `credentialId` must point to an owned Jira credential with email, + API token, and Jira base URL +- **Operations**: + - `getMyself`: authenticated Jira user + - `listProjects`: projects visible to the user + - `searchIssues`: search issues with `jiraJql` via `/search/jql`; defaults to + `updated >= -30d ORDER BY updated DESC`; optional `jiraFields` (defaults to + `key`, `summary`, `status`, `assignee`, `issuetype`) and `jiraNextPageToken` + - `getIssue`: fetch by key or ID such as `ENG-123` + - `createIssue`: requires `jiraProjectKey` and `jiraSummary`; optional `jiraIssueType`, + `jiraIssueTypeId`, `jiraDescription`, `jiraAssigneeAccountId`, and `jiraLabels` + - `updateIssue`: requires `jiraIssueKey` and at least one changed field + - `deleteIssue`: requires `jiraIssueKey` + - `getIssueChangelog`: changelog entries for `jiraIssueKey` + - `notifyIssue`: requires `jiraIssueKey`, `jiraNotifySubject`, and `jiraNotifyTextBody` + - `listComments`: comments for `jiraIssueKey` + - `createComment`: requires `jiraIssueKey` and `jiraCommentBody` + - `getComment`: requires `jiraIssueKey` and `jiraCommentId` + - `updateComment`: requires `jiraIssueKey`, `jiraCommentId`, and `jiraCommentBody` + - `deleteComment`: requires `jiraIssueKey` and `jiraCommentId` + - `listTransitions`: available transitions for `jiraIssueKey` + - `transitionIssue`: requires `jiraIssueKey` and `jiraTransitionId` + - `addAttachment`: requires `jiraIssueKey`, `jiraAttachmentFilename`, and `jiraAttachmentBase64` + - `getAttachment`: requires `jiraAttachmentId`; optional `jiraIncludeBinary` + - `listAttachments`: requires `jiraIssueKey`; optional `jiraIncludeBinary` + - `deleteAttachment`: requires `jiraAttachmentId` + - `getUser`: requires `jiraAccountId` + - `createUser`: requires `jiraUserEmail`; optional `jiraUserDisplayName` and `jiraUserProducts` + (admin permissions on Jira Cloud) + - `deleteUser`: requires `jiraAccountId` (admin permissions on Jira Cloud) +- **Fields**: `jiraOperation`, `jiraProjectKey`, `jiraIssueKey`, `jiraIssueType`, + `jiraIssueTypeId`, `jiraSummary`, `jiraDescription`, `jiraJql`, `jiraFields`, + `jiraAssigneeAccountId`, `jiraLabels`, `jiraCommentBody`, `jiraCommentId`, + `jiraTransitionId`, `jiraNotifySubject`, `jiraAttachmentId`, `jiraAttachmentFilename`, + `jiraAttachmentBase64`, `jiraAttachmentMimeType`, `jiraIncludeBinary`, + `jiraNotifyTextBody`, `jiraNotifyHtmlBody`, `jiraNotifyTo`, `jiraAccountId`, + `jiraUserEmail`, `jiraUserDisplayName`, `jiraUserProducts`, `jiraLimit` (1-100), + `jiraStartAt`, `jiraNextPageToken` +- Text fields support expressions. `jiraLabels`, `jiraUserProducts`, and `jiraFields` may be a + JSON array of strings or comma-separated text. `jiraNotifyTo` must be a JSON object and + defaults to `{"assignee":true}` when omitted. `searchIssues` pagination uses + `pagination.nextPageToken` instead of `startAt`. +- List outputs contain `{success, operation, count, projects|issues|comments|changelog|attachments, pagination}`. + `searchIssues` uses cursor pagination (`pagination.nextPageToken`); other paginated lists use + `startAt`. `listAttachments` pagination is client-side after fetching all issue attachments. +- Issue outputs contain `{success, operation, issue, key}`. +- Comment get/create/update outputs `{success, operation, comment}`; comment delete outputs + `{success, operation, deleted}`. +- `listTransitions` outputs `{success, operation, transitions, count}` without pagination. + `transitionIssue` outputs `{success, operation, transition, issue, key}`. +- Attachment add outputs `{success, operation, attachments, count}`. List attachments also + includes `pagination`. Attachment get outputs `{success, operation, attachment}` and includes + `attachment.content_base64` when `jiraIncludeBinary` is true. Attachment delete outputs + `{success, operation, deleted}`. +- User get/create outputs `{success, operation, user}`; delete outputs `{success, operation, deleted}`. +- Notify outputs `{success, operation, notified}`. +- Set update fields to `null` to clear description or assignee. + +**Example — create a Jira issue:** +```json +{ + "id": "jira-1", + "type": "jira", + "position": {"x": 500, "y": 100}, + "data": { + "label": "createJiraIssue", + "credentialId": "YOUR_CREDENTIAL_ID", + "jiraOperation": "createIssue", + "jiraProjectKey": "ENG", + "jiraIssueType": "Task", + "jiraSummary": "$input.title", + "jiraDescription": "$input.description", + "jiraLabels": "[\"automation\"]" + } +} +``` + ### 35. github (GitHub REST Operations) - **Type**: `github` - **Purpose**: Manage repositories, users, issues, pull requests, reviews, releases, Actions @@ -4303,7 +4384,7 @@ 21. **MULTIPLE INPUT FIELDS** - textInput nodes support multiple input fields via `inputFields` array. Define fields like `[{"key": "text"}, {"key": "base64"}]`. Access via `$nodeLabel.body.fieldKey`. Input values are sent in the `body` object. 22. **⚠️ NO UNNECESSARY textInput!** - NEVER add textInput unless user explicitly needs to provide input data. For static URLs, scheduled tasks, or fixed operations, START DIRECTLY with http, cron, or other nodes. textInput is ONLY for workflows that receive dynamic data from users/API callers. 23. **⚠️ PRESERVE CREDENTIALS & MODEL** - When modifying an existing workflow, ALWAYS preserve existing `credentialId` and `model` values in nodes. NEVER replace, remove, or change credential IDs or model names unless the user explicitly asks to use a different credential or model. If a node already has a `credentialId` or `model`, keep them exactly as is. -23a. **⚠️ CREDENTIALS & INTEGRATIONS - OWNED ONLY (NO SHARED)** - For **every** node field that references a credential or secret (`credentialId`, `githubCredentialId`, `fallbackCredentialId`, `guardrailCredentialId`, Playwright `aiStep` credential, etc.), use ONLY credentials **owned** by the workflow owner. **NEVER** put shared credentials (shared with you by another user or via team share) in generated JSON—the UI labels these as shared; they must not appear in AI output. Use placeholders such as `YOUR_CREDENTIAL_ID`, `codex-credential-uuid`, `github-credential-uuid`, `slack-credential-uuid`, `telegram-credential-uuid`, or `imap-credential-uuid` and let the user pick an owned credential in the editor. Applies to: `llm`, `agent`, `codex`, `slack`, `telegram`, `slackTrigger`, `telegramTrigger`, `imapTrigger`, `sendEmail`, `redis`, `grist`, `github`, `linear`, `googleSheets`, `bigquery`, `supabase`, `notion`, `rabbitmq`, `crawler`, `playwright` (including `aiStep`), and any other integration that stores a credential id. When modifying an existing workflow (rule 23), still preserve existing ids if they are already non-shared; when **adding** new nodes, never insert shared credential UUIDs. +23a. **⚠️ CREDENTIALS & INTEGRATIONS - OWNED ONLY (NO SHARED)** - For **every** node field that references a credential or secret (`credentialId`, `githubCredentialId`, `fallbackCredentialId`, `guardrailCredentialId`, Playwright `aiStep` credential, etc.), use ONLY credentials **owned** by the workflow owner. **NEVER** put shared credentials (shared with you by another user or via team share) in generated JSON—the UI labels these as shared; they must not appear in AI output. Use placeholders such as `YOUR_CREDENTIAL_ID`, `codex-credential-uuid`, `github-credential-uuid`, `jira-credential-uuid`, `slack-credential-uuid`, `telegram-credential-uuid`, or `imap-credential-uuid` and let the user pick an owned credential in the editor. Applies to: `llm`, `agent`, `codex`, `slack`, `telegram`, `slackTrigger`, `telegramTrigger`, `imapTrigger`, `sendEmail`, `redis`, `grist`, `github`, `jira`, `linear`, `googleSheets`, `bigquery`, `supabase`, `notion`, `rabbitmq`, `crawler`, `playwright` (including `aiStep`), and any other integration that stores a credential id. When modifying an existing workflow (rule 23), still preserve existing ids if they are already non-shared; when **adding** new nodes, never insert shared credential UUIDs. 24. **EXECUTE NODE OUTPUT** - Execute node returns `{status, outputs, workflow_id, execution_time_ms}`. Access the called workflow's result via `$executeNodeLabel.outputs.output.result`. The `outputs.output` object contains the result from the executed workflow's output node. 25. **EXECUTE NODE MULTIPLE INPUTS** - When calling a workflow that expects multiple input fields: (1) Add matching `inputFields` to your textInput node to collect all required data, (2) Use `executeInputMappings` array to map each field. Example: If target needs `text` and `imageUrl`, your textInput should have `inputFields: [{"key": "prompt"}, {"key": "image"}]`, then execute node uses `"executeInputMappings": [{"key": "text", "value": "$userInput.body.prompt"}, {"key": "imageUrl", "value": "$userInput.body.image"}]` 26. **REQUEST BODY, HEADERS & QUERY** - When workflow is executed via API, textInput nodes receive `body`, `headers` and `query` objects. Access via `$textInputLabel.body.fieldName`, `$textInputLabel.headers.headerName` and `$textInputLabel.query.paramName`. Useful for accessing raw request data, authentication, and dynamic behavior. diff --git a/backend/tests/test_alembic_migrations.py b/backend/tests/test_alembic_migrations.py index 75af8edb..b0d6423a 100644 --- a/backend/tests/test_alembic_migrations.py +++ b/backend/tests/test_alembic_migrations.py @@ -15,7 +15,7 @@ def setUp(self) -> None: self.script = ScriptDirectory.from_config(config) def test_revision_graph_has_one_head(self) -> None: - self.assertEqual(self.script.get_heads(), ["093_add_codex_node_support"]) + self.assertEqual(self.script.get_heads(), ["094_add_jira_credential_type"]) def test_plugins_revision_follows_workflow_timeout(self) -> None: plugins_revision = self.script.get_revision("090_add_plugins_table") @@ -41,6 +41,12 @@ def test_codex_revision_follows_sentry_revision(self) -> None: self.assertIsNotNone(codex_revision) self.assertEqual(codex_revision.down_revision, "092_add_sentry_credential_type") + def test_jira_revision_follows_codex_revision(self) -> None: + jira_revision = self.script.get_revision("094_add_jira_credential_type") + + self.assertIsNotNone(jira_revision) + self.assertEqual(jira_revision.down_revision, "093_add_codex_node_support") + def test_github_and_supabase_revisions_are_merged(self) -> None: merge_revision = self.script.get_revision("080_merge_github_supabase_heads") diff --git a/backend/tests/test_jira_node.py b/backend/tests/test_jira_node.py new file mode 100644 index 00000000..b9925d90 --- /dev/null +++ b/backend/tests/test_jira_node.py @@ -0,0 +1,1063 @@ +"""Unit tests for JiraService and the Jira node handler.""" + +import unittest +from collections.abc import Iterator +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +import httpx + +from app.db.models import CredentialType +from app.services.jira_service import JiraService +from app.services.node_execution.base import NodeExecutionContext +from app.services.node_execution.nodes.jira_node import execute + + +def _response( + payload: object | None = None, + status_code: int = 200, + method: str = "GET", + url: str = "https://example.atlassian.net/rest/api/3/myself", +) -> httpx.Response: + request = httpx.Request(method, url) + if payload is None: + return httpx.Response(status_code=status_code, request=request) + return httpx.Response(status_code=status_code, json=payload, request=request) + + +class JiraServiceTests(unittest.TestCase): + def test_get_myself_uses_basic_auth_and_base_url(self) -> None: + client = MagicMock() + client.request.return_value = _response({"accountId": "acct-1", "displayName": "Ada"}) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + result = service.get_myself() + + self.assertEqual(result["displayName"], "Ada") + client.request.assert_called_once() + self.assertEqual( + client.request.call_args.args[:2], + ("GET", "https://example.atlassian.net/rest/api/3/myself"), + ) + self.assertEqual(client.request.call_args.kwargs["auth"], ("ada@example.com", "jira-token")) + + def test_create_issue_sends_adf_description_and_labels(self) -> None: + client = MagicMock() + client.request.return_value = _response( + {"id": "10001", "key": "ENG-1"}, + method="POST", + url="https://example.atlassian.net/rest/api/3/issue", + ) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + issue = service.create_issue( + "ENG", + "Bug", + "Fix checkout", + description="Checkout is broken", + assignee_account_id="acct-1", + labels=["automation"], + ) + + self.assertEqual(issue["key"], "ENG-1") + fields = client.request.call_args.kwargs["json"]["fields"] + self.assertEqual(fields["project"], {"key": "ENG"}) + self.assertEqual(fields["issuetype"], {"name": "Bug"}) + self.assertEqual(fields["summary"], "Fix checkout") + self.assertEqual(fields["assignee"], {"accountId": "acct-1"}) + self.assertEqual(fields["labels"], ["automation"]) + self.assertEqual(fields["description"]["type"], "doc") + self.assertEqual(len(fields["description"]["content"]), 1) + + def test_create_issue_prefers_issue_type_id(self) -> None: + client = MagicMock() + client.request.return_value = _response( + {"id": "10001", "key": "ENG-1"}, + method="POST", + url="https://example.atlassian.net/rest/api/3/issue", + ) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + service.create_issue("ENG", "Bug", "Fix checkout", issue_type_id="10001") + + fields = client.request.call_args.kwargs["json"]["fields"] + self.assertEqual(fields["issuetype"], {"id": "10001"}) + + def test_search_issues_uses_bounded_default_jql(self) -> None: + client = MagicMock() + client.request.return_value = _response({"issues": [], "isLast": True}, method="POST") + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + service.search_issues("") + + self.assertEqual( + client.request.call_args.kwargs["json"]["jql"], + "updated >= -30d ORDER BY updated DESC", + ) + + def test_search_issues_uses_search_jql_post(self) -> None: + client = MagicMock() + client.request.return_value = _response( + { + "issues": [{"id": "10001", "key": "ENG-1"}], + "nextPageToken": "token-2", + "isLast": False, + }, + method="POST", + url="https://example.atlassian.net/rest/api/3/search/jql", + ) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + result = service.search_issues( + "project = ENG", + 25, + next_page_token="token-1", + fields=["key", "summary"], + ) + + self.assertEqual(result["nextPageToken"], "token-2") + self.assertEqual( + client.request.call_args.args[:2], + ("POST", "https://example.atlassian.net/rest/api/3/search/jql"), + ) + self.assertEqual( + client.request.call_args.kwargs["json"], + { + "jql": "project = ENG", + "maxResults": 25, + "fields": ["key", "summary"], + "nextPageToken": "token-1", + }, + ) + + def test_adf_text_document_splits_newlines(self) -> None: + document = JiraService._adf_text_document("line one\nline two") + self.assertEqual(len(document["content"]), 2) + self.assertEqual(document["content"][0]["content"][0]["text"], "line one") + self.assertEqual(document["content"][1]["content"][0]["text"], "line two") + + def test_update_issue_requires_at_least_one_field(self) -> None: + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=MagicMock(), + ) + + with self.assertRaisesRegex(ValueError, "at least one field"): + service.update_issue("ENG-1") + + def test_get_issue_changelog_uses_pagination(self) -> None: + client = MagicMock() + client.request.return_value = _response({"histories": [{"id": "10001"}], "total": 1}) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + result = service.get_issue_changelog("ENG-1", limit=25, start_at=10) + + self.assertEqual(result["total"], 1) + self.assertEqual( + client.request.call_args.args[:2], + ("GET", "https://example.atlassian.net/rest/api/3/issue/ENG-1/changelog"), + ) + self.assertEqual( + client.request.call_args.kwargs["params"], {"maxResults": 25, "startAt": 10} + ) + + def test_notify_issue_sends_recipient_payload(self) -> None: + client = MagicMock() + client.request.return_value = _response(None, status_code=204, method="POST") + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + notified = service.notify_issue( + "ENG-1", + subject="Heads up", + text_body="Issue changed", + to={"watchers": True}, + ) + + self.assertTrue(notified) + self.assertEqual( + client.request.call_args.args[:2], + ("POST", "https://example.atlassian.net/rest/api/3/issue/ENG-1/notify"), + ) + self.assertEqual( + client.request.call_args.kwargs["json"], + {"subject": "Heads up", "textBody": "Issue changed", "to": {"watchers": True}}, + ) + + def test_update_comment_sends_adf_body(self) -> None: + client = MagicMock() + client.request.return_value = _response({"id": "10001", "body": {}}, method="PUT") + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + comment = service.update_comment("ENG-1", "10001", "Updated") + + self.assertEqual(comment["id"], "10001") + self.assertEqual( + client.request.call_args.args[:2], + ("PUT", "https://example.atlassian.net/rest/api/3/issue/ENG-1/comment/10001"), + ) + self.assertEqual(client.request.call_args.kwargs["json"]["body"]["type"], "doc") + + def test_create_user_sends_optional_fields(self) -> None: + client = MagicMock() + client.request.return_value = _response({"accountId": "acct-1"}, method="POST") + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + user = service.create_user( + "ada@example.com", + display_name="Ada", + products=["jira-software"], + ) + + self.assertEqual(user["accountId"], "acct-1") + self.assertEqual( + client.request.call_args.args[:2], + ("POST", "https://example.atlassian.net/rest/api/3/user"), + ) + self.assertEqual( + client.request.call_args.kwargs["json"], + { + "emailAddress": "ada@example.com", + "displayName": "Ada", + "products": ["jira-software"], + }, + ) + + def test_add_attachment_sends_multipart_payload(self) -> None: + client = MagicMock() + client.request.return_value = _response( + [{"id": "att-1", "filename": "report.txt"}], + method="POST", + url="https://example.atlassian.net/rest/api/3/issue/ENG-1/attachments", + ) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + attachments = service.add_attachment( + "ENG-1", + filename="report.txt", + content=b"hello", + mime_type="text/plain", + ) + + self.assertEqual(attachments[0]["id"], "att-1") + self.assertEqual( + client.request.call_args.args[:2], + ("POST", "https://example.atlassian.net/rest/api/3/issue/ENG-1/attachments"), + ) + self.assertEqual(client.request.call_args.kwargs["files"]["file"][0], "report.txt") + self.assertEqual(client.request.call_args.kwargs["files"]["file"][1], b"hello") + self.assertEqual(client.request.call_args.kwargs["files"]["file"][2], "text/plain") + self.assertEqual( + client.request.call_args.kwargs["headers"]["X-Atlassian-Token"], + "no-check", + ) + + def test_list_attachments_reads_issue_attachment_field(self) -> None: + client = MagicMock() + client.request.return_value = _response( + {"fields": {"attachment": [{"id": "att-1"}, {"id": "att-2"}]}} + ) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + result = service.list_attachments("ENG-1", limit=1, start_at=1) + + self.assertEqual(result["attachments"], [{"id": "att-2"}]) + self.assertEqual(result["startAt"], 1) + self.assertEqual(result["total"], 2) + self.assertTrue(result["isLast"]) + self.assertEqual(client.request.call_args.kwargs["params"], {"fields": "attachment"}) + + def test_transition_issue_refetches_issue(self) -> None: + client = MagicMock() + client.request.side_effect = [ + _response(None, status_code=204, method="POST"), + _response({"id": "10001", "key": "ENG-1", "fields": {"status": {"name": "Done"}}}), + ] + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + result = service.transition_issue("ENG-1", "31") + + self.assertEqual(result["transitionId"], "31") + self.assertEqual(result["issue"]["key"], "ENG-1") + + def test_http_error_detail_is_truncated(self) -> None: + client = MagicMock() + request = httpx.Request("GET", "https://example.atlassian.net/rest/api/3/myself") + client.request.return_value = httpx.Response( + 400, + content=("x" * 700).encode(), + request=request, + ) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + with self.assertRaisesRegex(ValueError, r"\.\.\.$"): + service.get_myself() + + def test_non_json_response_raises_readable_error(self) -> None: + client = MagicMock() + request = httpx.Request("GET", "https://example.atlassian.net/rest/api/3/myself") + client.request.return_value = httpx.Response(200, content=b"not json", request=request) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + client=client, + ) + + with self.assertRaisesRegex(ValueError, "non-JSON"): + service.get_myself() + + +class JiraNodeHandlerTests(unittest.TestCase): + def test_create_issue_resolves_fields_and_calls_service(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "createIssue", + "jiraProjectKey": "ENG", + "jiraIssueType": "Bug", + "jiraSummary": "$input.title", + "jiraDescription": "$input.description", + "jiraLabels": '["automation"]', + }, + {"title": "Fix checkout", "description": "Checkout is broken"}, + ) + mock_service = MagicMock() + mock_service.create_issue.return_value = {"id": "10001", "key": "ENG-1"} + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.create_issue.assert_called_once_with( + "ENG", + "Bug", + "Fix checkout", + description="Checkout is broken", + assignee_account_id=None, + labels=["automation"], + issue_type_id=None, + ) + self.assertEqual(result["key"], "ENG-1") + + def test_rejects_non_jira_credential(self) -> None: + ctx = _make_ctx( + {"credentialId": "cred-1", "jiraOperation": "listProjects"}, + credential_type=CredentialType.github, + ) + + with _mock_jira_credential(CredentialType.github): + with self.assertRaisesRegex(ValueError, "Jira credential"): + execute(ctx) + + def test_update_issue_propagates_service_validation_error(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "updateIssue", + "jiraIssueKey": "ENG-1", + } + ) + mock_service = MagicMock() + mock_service.update_issue.side_effect = ValueError( + "Jira updateIssue requires at least one field to update" + ) + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + with self.assertRaisesRegex(ValueError, "at least one field"): + execute(ctx) + + mock_service.update_issue.assert_called_once() + + def test_notify_issue_resolves_payload_fields(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "notifyIssue", + "jiraIssueKey": "ENG-1", + "jiraNotifySubject": "$input.subject", + "jiraNotifyTextBody": "$input.body", + "jiraNotifyTo": '{"watchers":true}', + }, + {"subject": "Heads up", "body": "Issue changed"}, + ) + mock_service = MagicMock() + mock_service.notify_issue.return_value = True + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.notify_issue.assert_called_once_with( + "ENG-1", + subject="Heads up", + text_body="Issue changed", + html_body=None, + to={"watchers": True}, + ) + self.assertTrue(result["notified"]) + + def test_update_comment_requires_comment_id_and_body(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "updateComment", + "jiraIssueKey": "ENG-1", + "jiraCommentId": "10001", + "jiraCommentBody": "$input.body", + }, + {"body": "Updated comment"}, + ) + mock_service = MagicMock() + mock_service.update_comment.return_value = {"id": "10001"} + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.update_comment.assert_called_once_with("ENG-1", "10001", "Updated comment") + self.assertEqual(result["comment"]["id"], "10001") + + def test_create_user_parses_product_list(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "createUser", + "jiraUserEmail": "$input.email", + "jiraUserDisplayName": "Ada", + "jiraUserProducts": '["jira-software"]', + }, + {"email": "ada@example.com"}, + ) + mock_service = MagicMock() + mock_service.create_user.return_value = {"accountId": "acct-1"} + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.create_user.assert_called_once_with( + "ada@example.com", + display_name="Ada", + products=["jira-software"], + ) + self.assertEqual(result["user"]["accountId"], "acct-1") + + def test_add_attachment_decodes_base64_content(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "addAttachment", + "jiraIssueKey": "ENG-1", + "jiraAttachmentFilename": "report.txt", + "jiraAttachmentBase64": "aGVsbG8=", + "jiraAttachmentMimeType": "text/plain", + } + ) + mock_service = MagicMock() + mock_service.add_attachment.return_value = [{"id": "att-1"}] + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.add_attachment.assert_called_once_with( + "ENG-1", + filename="report.txt", + content=b"hello", + mime_type="text/plain", + ) + self.assertEqual(result["attachments"], [{"id": "att-1"}]) + + def test_get_attachment_can_include_binary_content(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "getAttachment", + "jiraAttachmentId": "att-1", + "jiraIncludeBinary": True, + } + ) + mock_service = MagicMock() + mock_service.get_attachment.return_value = { + "id": "att-1", + "content": "https://example.atlassian.net/secure/attachment/att-1/report.txt", + } + mock_service.download_attachment.return_value = b"hello" + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.download_attachment.assert_called_once() + self.assertEqual(result["attachment"]["content_base64"], "aGVsbG8=") + + def test_get_issue_changelog_returns_histories(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "getIssueChangelog", + "jiraIssueKey": "ENG-1", + } + ) + mock_service = MagicMock() + mock_service.get_issue_changelog.return_value = { + "histories": [{"id": "10001", "items": []}], + "total": 1, + } + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + self.assertEqual(result["count"], 1) + self.assertEqual(result["changelog"], [{"id": "10001", "items": []}]) + + def test_search_issues_passes_next_page_token_and_fields(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "searchIssues", + "jiraJql": "$input.jql", + "jiraNextPageToken": "token-1", + "jiraFields": '["key","summary"]', + "jiraLimit": "25", + }, + {"jql": "project = ENG"}, + ) + mock_service = MagicMock() + mock_service.search_issues.return_value = { + "issues": [{"id": "10001", "key": "ENG-1"}], + "nextPageToken": "token-2", + "isLast": False, + } + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.search_issues.assert_called_once_with( + "project = ENG", + 25, + next_page_token="token-1", + fields=["key", "summary"], + ) + self.assertEqual(result["pagination"]["nextPageToken"], "token-2") + self.assertEqual(result["count"], 1) + + def test_create_issue_uses_issue_type_id_when_provided(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "createIssue", + "jiraProjectKey": "ENG", + "jiraIssueType": "Bug", + "jiraIssueTypeId": "10001", + "jiraSummary": "Fix checkout", + } + ) + mock_service = MagicMock() + mock_service.create_issue.return_value = {"id": "10001", "key": "ENG-1"} + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + execute(ctx) + + mock_service.create_issue.assert_called_once_with( + "ENG", + "Bug", + "Fix checkout", + description=None, + assignee_account_id=None, + labels=None, + issue_type_id="10001", + ) + + def test_transition_issue_returns_updated_issue(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "transitionIssue", + "jiraIssueKey": "ENG-1", + "jiraTransitionId": "31", + } + ) + mock_service = MagicMock() + mock_service.transition_issue.return_value = { + "transitionId": "31", + "issue": {"id": "10001", "key": "ENG-1"}, + } + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + self.assertEqual(result["key"], "ENG-1") + self.assertEqual(result["transition"]["transitionId"], "31") + + def test_get_myself_returns_user(self) -> None: + ctx = _make_ctx({"credentialId": "cred-1", "jiraOperation": "getMyself"}) + mock_service = MagicMock() + mock_service.get_myself.return_value = {"accountId": "acct-1", "displayName": "Ada"} + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.get_myself.assert_called_once_with() + self.assertEqual(result["user"]["displayName"], "Ada") + + def test_list_projects_returns_projects_and_pagination(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "listProjects", + "jiraLimit": "25", + "jiraStartAt": "10", + } + ) + mock_service = MagicMock() + mock_service.list_projects.return_value = { + "values": [{"id": "10000", "key": "ENG"}], + "startAt": 10, + "maxResults": 25, + "total": 1, + "isLast": True, + } + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.list_projects.assert_called_once_with(25, 10) + self.assertEqual(result["count"], 1) + self.assertEqual(result["projects"][0]["key"], "ENG") + + def test_get_issue_returns_issue(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "getIssue", + "jiraIssueKey": "ENG-1", + } + ) + mock_service = MagicMock() + mock_service.get_issue.return_value = {"id": "10001", "key": "ENG-1"} + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.get_issue.assert_called_once_with("ENG-1") + self.assertEqual(result["key"], "ENG-1") + + def test_delete_issue_returns_deleted_flag(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "deleteIssue", + "jiraIssueKey": "ENG-1", + } + ) + mock_service = MagicMock() + mock_service.delete_issue.return_value = True + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.delete_issue.assert_called_once_with("ENG-1") + self.assertTrue(result["deleted"]) + + def test_list_comments_returns_comments(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "listComments", + "jiraIssueKey": "ENG-1", + "jiraLimit": "25", + "jiraStartAt": "5", + } + ) + mock_service = MagicMock() + mock_service.list_comments.return_value = { + "comments": [{"id": "10001", "body": {}}], + "startAt": 5, + "maxResults": 25, + "total": 1, + "isLast": True, + } + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.list_comments.assert_called_once_with("ENG-1", 25, 5) + self.assertEqual(result["count"], 1) + + def test_create_comment_resolves_body(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "createComment", + "jiraIssueKey": "ENG-1", + "jiraCommentBody": "$input.body", + }, + {"body": "New comment"}, + ) + mock_service = MagicMock() + mock_service.create_comment.return_value = {"id": "10001"} + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.create_comment.assert_called_once_with("ENG-1", "New comment") + self.assertEqual(result["comment"]["id"], "10001") + + def test_list_transitions_returns_transitions(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "listTransitions", + "jiraIssueKey": "ENG-1", + } + ) + mock_service = MagicMock() + mock_service.list_transitions.return_value = [{"id": "31", "name": "Done"}] + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.list_transitions.assert_called_once_with("ENG-1") + self.assertEqual(result["count"], 1) + + def test_delete_attachment_returns_deleted_flag(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "deleteAttachment", + "jiraAttachmentId": "att-1", + } + ) + mock_service = MagicMock() + mock_service.delete_attachment.return_value = True + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.delete_attachment.assert_called_once_with("att-1") + self.assertTrue(result["deleted"]) + + def test_get_user_returns_user(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "getUser", + "jiraAccountId": "acct-1", + } + ) + mock_service = MagicMock() + mock_service.get_user.return_value = {"accountId": "acct-1"} + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.get_user.assert_called_once_with("acct-1") + self.assertEqual(result["user"]["accountId"], "acct-1") + + def test_delete_user_returns_deleted_flag(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "deleteUser", + "jiraAccountId": "acct-1", + } + ) + mock_service = MagicMock() + mock_service.delete_user.return_value = True + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.delete_user.assert_called_once_with("acct-1") + self.assertTrue(result["deleted"]) + + def test_update_issue_null_expression_clears_assignee(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "updateIssue", + "jiraIssueKey": "ENG-1", + "jiraAssigneeAccountId": "null", + } + ) + mock_service = MagicMock() + mock_service.update_issue.return_value = {"id": "10001", "key": "ENG-1"} + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.update_issue.assert_called_once() + self.assertIsNone(mock_service.update_issue.call_args.kwargs["assignee_account_id"]) + self.assertEqual(result["key"], "ENG-1") + + def test_add_attachment_decodes_data_url_content(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "addAttachment", + "jiraIssueKey": "ENG-1", + "jiraAttachmentFilename": "report.txt", + "jiraAttachmentBase64": "data:text/plain;base64,aGVsbG8=", + } + ) + mock_service = MagicMock() + mock_service.add_attachment.return_value = [{"id": "att-1"}] + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + execute(ctx) + + mock_service.add_attachment.assert_called_once_with( + "ENG-1", + filename="report.txt", + content=b"hello", + mime_type="text/plain", + ) + + def test_list_attachments_can_include_binary_content(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "listAttachments", + "jiraIssueKey": "ENG-1", + "jiraIncludeBinary": True, + "jiraStartAt": "1", + } + ) + mock_service = MagicMock() + mock_service.list_attachments.return_value = { + "attachments": [ + { + "id": "att-2", + "content": "https://example.atlassian.net/secure/attachment/att-2/report.txt", + } + ], + "startAt": 1, + "maxResults": 50, + "total": 2, + "isLast": True, + } + mock_service.download_attachment.return_value = b"hello" + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + result = execute(ctx) + + mock_service.list_attachments.assert_called_once_with("ENG-1", 50, 1) + mock_service.download_attachment.assert_called_once() + self.assertEqual(result["attachments"][0]["content_base64"], "aGVsbG8=") + + def test_create_issue_parses_comma_separated_labels(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "createIssue", + "jiraProjectKey": "ENG", + "jiraSummary": "Fix checkout", + "jiraLabels": "automation, bug", + } + ) + mock_service = MagicMock() + mock_service.create_issue.return_value = {"id": "10001", "key": "ENG-1"} + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + execute(ctx) + + mock_service.create_issue.assert_called_once_with( + "ENG", + "Task", + "Fix checkout", + description=None, + assignee_account_id=None, + labels=["automation", "bug"], + issue_type_id=None, + ) + + def test_search_issues_parses_comma_separated_fields(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "searchIssues", + "jiraJql": "project = ENG", + "jiraFields": "key, summary, status", + } + ) + mock_service = MagicMock() + mock_service.search_issues.return_value = {"issues": [], "isLast": True} + + with _mock_jira_credential(CredentialType.jira): + with patch("app.services.jira_service.JiraService", return_value=mock_service): + execute(ctx) + + mock_service.search_issues.assert_called_once_with( + "project = ENG", + 50, + next_page_token=None, + fields=["key", "summary", "status"], + ) + + +def _make_ctx( + node_data: dict, + inputs: dict | None = None, + credential_type: CredentialType = CredentialType.jira, +) -> NodeExecutionContext: + executor = MagicMock() + + def evaluate_message_template(value: str, current_inputs: dict, _node_id: str) -> str: + if value.startswith("$input."): + return str(current_inputs.get(value.removeprefix("$input."), "")) + return value + + executor.evaluate_message_template.side_effect = evaluate_message_template + executor._get_accessible_credential.return_value = MagicMock( + encrypted_config="{}", + type=credential_type, + ) + return NodeExecutionContext( + executor=executor, + node_id="jira-1", + inputs=inputs or {}, + allow_branch_skip=False, + start_time=0, + node={"id": "jira-1", "type": "jira", "data": node_data}, + node_type="jira", + node_data=node_data, + node_label="jiraNode", + ) + + +@contextmanager +def _mock_jira_credential(credential_type: CredentialType) -> Iterator[None]: + db = MagicMock() + db.__enter__ = MagicMock(return_value=db) + db.__exit__ = MagicMock(return_value=False) + db.query.return_value.filter.return_value.first.return_value = MagicMock( + encrypted_config="{}", + type=credential_type, + ) + with patch("app.db.session.SessionLocal", return_value=db): + with patch( + "app.services.encryption.decrypt_config", + return_value={ + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + }, + ): + yield diff --git a/frontend/e2e/jira-node-properties.spec.ts b/frontend/e2e/jira-node-properties.spec.ts new file mode 100644 index 00000000..eb3f8e79 --- /dev/null +++ b/frontend/e2e/jira-node-properties.spec.ts @@ -0,0 +1,198 @@ +import { expect, test } from "@playwright/test"; + +import { createWorkflow, deleteWorkflow, prepareAuthenticatedPage } from "./support"; + +test.beforeEach(async ({ page }) => { + await prepareAuthenticatedPage(page); +}); + +test("shows Jira node operation-specific fields", async ({ page }) => { + const workflow = await createWorkflow( + page, + `Jira Node Properties ${Date.now()}`, + [ + { + id: "jira-node", + type: "jira", + position: { x: 120, y: 160 }, + data: { + label: "jira", + credentialId: "", + jiraOperation: "createIssue", + jiraProjectKey: "", + jiraIssueType: "Task", + jiraSummary: "", + jiraDescription: "$input.text", + }, + }, + ], + ); + + try { + await page.goto(`/workflows/${workflow.id}`); + await expect(page.locator(".vue-flow__node")).toHaveCount(1); + await page.getByRole("button", { name: "Properties", exact: true }).click(); + await page.locator('.vue-flow__node[data-id="jira-node"]').click(); + + const panel = page.locator(".properties-panel"); + await expect(panel.getByTestId("jira-credential-field")).toBeVisible(); + await expect(panel.getByTestId("jira-operation-field")).toBeVisible(); + await expect(panel.getByTestId("jira-project-key-field")).toBeVisible(); + await expect(panel.getByTestId("jira-issue-type-field")).toBeVisible(); + await expect(panel.getByTestId("jira-summary-field")).toBeVisible(); + await expect(panel.getByTestId("jira-description-field")).toBeVisible(); + } finally { + await deleteWorkflow(page, workflow.id); + } +}); + +test("shows Jira notify operation fields", async ({ page }) => { + const workflow = await createWorkflow( + page, + `Jira Notify Properties ${Date.now()}`, + [ + { + id: "jira-node", + type: "jira", + position: { x: 120, y: 160 }, + data: { + label: "jira", + credentialId: "", + jiraOperation: "notifyIssue", + jiraIssueKey: "ENG-1", + jiraNotifySubject: "Issue update", + jiraNotifyTextBody: "$input.text", + jiraNotifyTo: "{\"assignee\":true}", + }, + }, + ], + ); + + try { + await page.goto(`/workflows/${workflow.id}`); + await expect(page.locator(".vue-flow__node")).toHaveCount(1); + await page.getByRole("button", { name: "Properties", exact: true }).click(); + await page.locator('.vue-flow__node[data-id="jira-node"]').click(); + + const panel = page.locator(".properties-panel"); + await expect(panel.getByTestId("jira-issue-key-field")).toBeVisible(); + await expect(panel.getByTestId("jira-notify-subject-field")).toBeVisible(); + await expect(panel.getByTestId("jira-notify-text-body-field")).toBeVisible(); + await expect(panel.getByTestId("jira-notify-to-field")).toBeVisible(); + } finally { + await deleteWorkflow(page, workflow.id); + } +}); + +test("shows Jira attachment operation fields", async ({ page }) => { + const workflow = await createWorkflow( + page, + `Jira Attachment Properties ${Date.now()}`, + [ + { + id: "jira-node", + type: "jira", + position: { x: 120, y: 160 }, + data: { + label: "jira", + credentialId: "", + jiraOperation: "addAttachment", + jiraIssueKey: "ENG-1", + jiraAttachmentFilename: "report.txt", + jiraAttachmentBase64: "aGVsbG8=", + }, + }, + ], + ); + + try { + await page.goto(`/workflows/${workflow.id}`); + await expect(page.locator(".vue-flow__node")).toHaveCount(1); + await page.getByRole("button", { name: "Properties", exact: true }).click(); + await page.locator('.vue-flow__node[data-id="jira-node"]').click(); + + const panel = page.locator(".properties-panel"); + await expect(panel.getByTestId("jira-issue-key-field")).toBeVisible(); + await expect(panel.getByTestId("jira-attachment-filename-field")).toBeVisible(); + await expect(panel.getByTestId("jira-attachment-base64-field")).toBeVisible(); + await expect(panel.getByTestId("jira-attachment-mime-type-field")).toBeVisible(); + } finally { + await deleteWorkflow(page, workflow.id); + } +}); + +test("shows Jira search operation fields", async ({ page }) => { + const workflow = await createWorkflow( + page, + `Jira Search Properties ${Date.now()}`, + [ + { + id: "jira-node", + type: "jira", + position: { x: 120, y: 160 }, + data: { + label: "jira", + credentialId: "", + jiraOperation: "searchIssues", + jiraJql: "project = ENG AND updated >= -30d ORDER BY updated DESC", + jiraFields: "[\"key\",\"summary\"]", + jiraLimit: "25", + jiraNextPageToken: "", + }, + }, + ], + ); + + try { + await page.goto(`/workflows/${workflow.id}`); + await expect(page.locator(".vue-flow__node")).toHaveCount(1); + await page.getByRole("button", { name: "Properties", exact: true }).click(); + await page.locator('.vue-flow__node[data-id="jira-node"]').click(); + + const panel = page.locator(".properties-panel"); + await expect(panel.getByTestId("jira-jql-field")).toBeVisible(); + await expect(panel.getByTestId("jira-fields-field")).toBeVisible(); + await expect(panel.getByText("Limit", { exact: true })).toBeVisible(); + await expect(panel.getByText("Next Page Token", { exact: true })).toBeVisible(); + } finally { + await deleteWorkflow(page, workflow.id); + } +}); + +test("shows Jira list attachments operation fields", async ({ page }) => { + const workflow = await createWorkflow( + page, + `Jira List Attachments Properties ${Date.now()}`, + [ + { + id: "jira-node", + type: "jira", + position: { x: 120, y: 160 }, + data: { + label: "jira", + credentialId: "", + jiraOperation: "listAttachments", + jiraIssueKey: "ENG-1", + jiraLimit: "25", + jiraStartAt: "0", + jiraIncludeBinary: true, + }, + }, + ], + ); + + try { + await page.goto(`/workflows/${workflow.id}`); + await expect(page.locator(".vue-flow__node")).toHaveCount(1); + await page.getByRole("button", { name: "Properties", exact: true }).click(); + await page.locator('.vue-flow__node[data-id="jira-node"]').click(); + + const panel = page.locator(".properties-panel"); + await expect(panel.getByTestId("jira-issue-key-field")).toBeVisible(); + await expect(panel.getByText("Limit", { exact: true })).toBeVisible(); + await expect(panel.getByText("Start At", { exact: true })).toBeVisible(); + await expect(panel.getByText("Include binary content as base64")).toBeVisible(); + } finally { + await deleteWorkflow(page, workflow.id); + } +}); diff --git a/frontend/src/components/Canvas/WorkflowCanvas.vue b/frontend/src/components/Canvas/WorkflowCanvas.vue index 58acc745..5cd523bf 100644 --- a/frontend/src/components/Canvas/WorkflowCanvas.vue +++ b/frontend/src/components/Canvas/WorkflowCanvas.vue @@ -1081,6 +1081,7 @@ function getDefaultNodeData(type: NodeType): WorkflowNode["data"] { rag: { label: "rag", vectorStoreId: "", ragOperation: undefined, documentContent: "$input.text", documentMetadata: "{}", queryText: "$input.text", searchLimit: 5, metadataFilters: "{}" }, grist: { label: "grist", credentialId: "", gristOperation: undefined, gristDocId: "", gristTableId: "", gristRecordId: "", gristRecordData: "{}", gristRecordsData: "[]", gristFilter: "{}", gristSort: "", gristLimit: 100, gristRecordIds: "[]" }, github: { label: "github", credentialId: "", githubOperation: "getRepository", githubOwner: "", githubRepo: "", githubOrganization: "", githubInviteEmail: "", githubIssueNumber: "", githubTitle: undefined, githubBody: undefined, githubCommentBody: "$input.text", githubState: undefined, githubStateReason: undefined, githubAssignee: "", githubCreator: "", githubMentioned: "", githubLabelsFilter: "", githubSince: "", githubSort: "", githubDirection: "", githubLabels: undefined, githubAssignees: undefined, githubLockReason: "", githubHead: "", githubBase: "main", githubPullRequestNumber: "", githubReviewId: "", githubReviewEvent: "APPROVE", githubReviewBody: "", githubCommitId: "", githubDraft: undefined, githubPrerelease: undefined, githubFilePath: "", githubFileContent: "$input.text", githubCommitMessage: "", githubBranch: "", githubPerPage: "30", githubTagName: "", githubReleaseId: "", githubWorkflowId: "", githubWorkflowInputs: undefined, githubWaitTimeoutSeconds: "600", githubPollIntervalSeconds: "5" }, + jira: { label: "jira", credentialId: "", jiraOperation: "searchIssues", jiraProjectKey: "", jiraIssueKey: "", jiraIssueType: "Task", jiraIssueTypeId: "", jiraSummary: "", jiraDescription: "$input.text", jiraJql: "updated >= -30d ORDER BY updated DESC", jiraFields: "", jiraAssigneeAccountId: "", jiraLabels: "", jiraCommentBody: "$input.text", jiraCommentId: "", jiraTransitionId: "", jiraAttachmentId: "", jiraAttachmentFilename: "", jiraAttachmentBase64: "", jiraAttachmentMimeType: "", jiraIncludeBinary: false, jiraNotifySubject: "", jiraNotifyTextBody: "$input.text", jiraNotifyHtmlBody: "", jiraNotifyTo: "{\"assignee\":true}", jiraAccountId: "", jiraUserEmail: "", jiraUserDisplayName: "", jiraUserProducts: "", jiraLimit: "50", jiraStartAt: "0", jiraNextPageToken: "" }, linear: { label: "linear", credentialId: "", linearOperation: "listIssues", linearTeamId: "", linearProjectId: "", linearIssueId: "", linearIssueLinkUrl: "", linearTitle: "", linearDescription: "$input.text", linearStateId: "", linearAssigneeId: "", linearPriority: "", linearCommentId: "", linearCommentBody: "$input.text", linearParentCommentId: "", linearLimit: "50", linearReturnAll: false }, googleSheets: { label: "googleSheets", credentialId: "", gsOperation: undefined, gsSpreadsheetId: "", gsSheetName: "Sheet1", gsStartRow: "1", gsUpdateRow: "1", gsMaxRows: "0", gsHasHeader: true, gsRowCount: "1", gsKeepHeader: false, gsAppendPlacement: "append", gsValuesInputMode: "raw", gsValuesSelectiveRows: "1", gsValuesSelectiveCols: "3", gsValues: "[]" }, bigquery: { label: "bigquery", credentialId: "", bqOperation: undefined, bqProjectId: "", bqQuery: "", bqMaxResults: "1000", bqDatasetId: "", bqTableId: "", bqRowsInputMode: "raw", bqRows: "[]", bqMappings: [{ key: "field", value: "$input.text" }] }, diff --git a/frontend/src/components/Credentials/CredentialDialog.vue b/frontend/src/components/Credentials/CredentialDialog.vue index 60f00bf7..6dcc3eb5 100644 --- a/frontend/src/components/Credentials/CredentialDialog.vue +++ b/frontend/src/components/Credentials/CredentialDialog.vue @@ -47,6 +47,11 @@ const codexSigningIn = ref(false); const codexSignInError = ref(""); const codexSignedInAccount = ref(""); const baseUrl = ref(""); +const jiraEmail = ref(""); +const jiraApiVersion = ref("3"); +const jiraTesting = ref(false); +const jiraTestSuccess = ref(null); +const jiraTestMessage = ref(""); const bearerToken = ref(""); const headerKey = ref(""); const headerValue = ref(""); @@ -187,6 +192,7 @@ const typeOptions = [ { value: "codex", label: CREDENTIAL_TYPE_LABELS.codex }, { value: "google", label: CREDENTIAL_TYPE_LABELS.google }, { value: "github", label: CREDENTIAL_TYPE_LABELS.github }, + { value: "jira", label: CREDENTIAL_TYPE_LABELS.jira }, { value: "linear", label: CREDENTIAL_TYPE_LABELS.linear }, { value: "elevenlabs", label: CREDENTIAL_TYPE_LABELS.elevenlabs }, { value: "custom", label: CREDENTIAL_TYPE_LABELS.custom }, @@ -235,7 +241,18 @@ watch( : "chatgpt"; codexSignedInAccount.value = props.credential.public_fields?.account_id || ""; - baseUrl.value = ""; + baseUrl.value = + props.credential.type === "jira" + ? props.credential.public_fields?.base_url ?? "" + : ""; + jiraEmail.value = + props.credential.type === "jira" + ? props.credential.public_fields?.email ?? "" + : ""; + jiraApiVersion.value = + props.credential.type === "jira" + ? props.credential.public_fields?.api_version ?? "3" + : "3"; bearerToken.value = ""; headerKey.value = props.credential.header_key || ""; headerValue.value = ""; @@ -343,6 +360,8 @@ watch( resetCodexOAuthState(); codexSignedInAccount.value = ""; baseUrl.value = ""; + jiraEmail.value = ""; + jiraApiVersion.value = "3"; bearerToken.value = ""; headerKey.value = ""; headerValue.value = ""; @@ -424,6 +443,9 @@ watch( linearTesting.value = false; linearTestSuccess.value = null; linearTestMessage.value = ""; + jiraTesting.value = false; + jiraTestSuccess.value = null; + jiraTestMessage.value = ""; notionTesting.value = false; notionTestSuccess.value = null; notionTestMessage.value = ""; @@ -459,6 +481,11 @@ const isValid = computed(() => { type.value === "elevenlabs" ) { return !!apiKey.value.trim() || isEditing.value; + } else if (type.value === "jira") { + if (isEditing.value) { + return !!jiraEmail.value.trim() && !!baseUrl.value.trim(); + } + return !!jiraEmail.value.trim() && !!apiKey.value.trim() && !!baseUrl.value.trim(); } else if (type.value === "codex") { if (codexAuthMode.value === "chatgpt") { return !!codexOAuthConfig.value || isEditing.value; @@ -591,6 +618,16 @@ const canTestLinearConnection = computed((): boolean => { return isEditing.value && !!props.credential?.id; }); +const canTestJiraConnection = computed((): boolean => { + if (type.value !== "jira") { + return false; + } + if (jiraEmail.value.trim() && baseUrl.value.trim() && apiKey.value.trim()) { + return true; + } + return isEditing.value && !!props.credential?.id && !!jiraEmail.value.trim() && !!baseUrl.value.trim(); +}); + function resetCodexOAuthState(): void { codexOAuthConfig.value = null; codexOAuthState.value = ""; @@ -655,6 +692,17 @@ function buildConfig(): CredentialConfig { api_key: apiKey.value, ...(trimmedBaseUrl ? { base_url: trimmedBaseUrl } : {}), }; + } else if (type.value === "jira") { + const config: Record = { + email: jiraEmail.value.trim(), + api_token: apiKey.value.trim(), + base_url: baseUrl.value.trim(), + }; + const apiVersion = jiraApiVersion.value.trim(); + if (apiVersion) { + config.api_version = apiVersion; + } + return config; } else if (type.value === "sentry") { const trimmedBaseUrl = baseUrl.value.trim(); return { @@ -1020,6 +1068,43 @@ async function testLinearConnection(): Promise { } } +async function testJiraConnection(): Promise { + if (!canTestJiraConnection.value) { + error.value = "Enter Jira email, API token, and base URL to test the connection."; + return; + } + + jiraTesting.value = true; + jiraTestSuccess.value = null; + jiraTestMessage.value = ""; + error.value = ""; + + try { + const result = await credentialsApi.testConnection({ + type: "jira", + config: { + email: jiraEmail.value.trim(), + api_token: apiKey.value.trim(), + base_url: baseUrl.value.trim(), + ...(jiraApiVersion.value.trim() ? { api_version: jiraApiVersion.value.trim() } : {}), + }, + credential_id: isEditing.value ? props.credential?.id : undefined, + }); + jiraTestSuccess.value = result.success; + jiraTestMessage.value = result.message; + if (!result.success) { + error.value = result.message; + } + } catch (err) { + const message = err instanceof Error ? err.message : "Connection test failed"; + jiraTestSuccess.value = false; + jiraTestMessage.value = message; + error.value = message; + } finally { + jiraTesting.value = false; + } +} + async function testNotionConnection(): Promise { const connectedCredentialId = props.credential?.id || notionConnectedCredential.value?.id; @@ -1582,6 +1667,87 @@ async function handleSave(): Promise { + +