diff --git a/backend/alembic/versions/095_add_jira_credential_type.py b/backend/alembic/versions/095_add_jira_credential_type.py new file mode 100644 index 00000000..67922ced --- /dev/null +++ b/backend/alembic/versions/095_add_jira_credential_type.py @@ -0,0 +1,24 @@ +"""add jira credential type + +Revision ID: 095_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 = "095_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/alembic/versions/096_merge_jira_file_heads.py b/backend/alembic/versions/096_merge_jira_file_heads.py new file mode 100644 index 00000000..509374e1 --- /dev/null +++ b/backend/alembic/versions/096_merge_jira_file_heads.py @@ -0,0 +1,24 @@ +"""Merge Jira and file team share migration heads. + +Revision ID: 096_merge_jira_file_heads +Revises: 095_add_jira_credential_type, 094_add_file_team_shares +Create Date: 2026-07-09 00:00:00.000000 +""" + +from collections.abc import Sequence + +revision: str = "096_merge_jira_file_heads" +down_revision: tuple[str, str] = ( + "095_add_jira_credential_type", + "094_add_file_team_shares", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Merge the Jira and file team share migration branches.""" + + +def downgrade() -> None: + """Split the migration graph back into its parent branches.""" diff --git a/backend/app/api/credentials.py b/backend/app/api/credentials.py index 4b5d93ff..fbee64ac 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", "deployment"): + 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,17 @@ 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 + deployment = str(config.get("deployment", "") or "").strip() or "cloud" + return { + "base_url": base_url, + "email": email, + "api_version": api_version, + "deployment": deployment, + } 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 +821,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 +850,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 +886,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 +1372,35 @@ 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", + ) + deployment = str(config.get("deployment", "") or "cloud").strip() + if deployment not in {"cloud", "data_center"}: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Jira credential deployment must be cloud or data_center", + ) 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 2024b0ca..18095f50 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 decf235d..bca699a4 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -473,6 +473,7 @@ class CredentialType(str, Enum): codex = "codex" google = "google" github = "github" + jira = "jira" linear = "linear" custom = "custom" bearer = "bearer" @@ -518,6 +519,14 @@ class CredentialConfigGitHub(BaseModel): base_url: str | None = None +class CredentialConfigJira(BaseModel): + email: str + api_token: str + base_url: str + deployment: str | None = None + 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..837ab9bc --- /dev/null +++ b/backend/app/services/jira_service.py @@ -0,0 +1,538 @@ +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_DEPLOYMENT = "cloud" +_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._deployment = self._normalize_deployment(self._config) + self._api_version = self._normalize_api_version(self._config, self._deployment) + 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.""" + normalized_limit = self._normalize_limit(limit) + offset = max(start_at, 0) + if self._is_data_center: + payload = self._request("GET", "/project") + projects = self._expect_project_list(payload) + page = projects[offset : offset + normalized_limit] + total = len(projects) + return { + "values": page, + "startAt": offset, + "maxResults": normalized_limit, + "total": total, + "isLast": offset + normalized_limit >= total, + } + payload = self._request( + "GET", + "/project/search", + params={"maxResults": normalized_limit, "startAt": offset}, + ) + return self._expect_object(payload, "project.search") + + def search_issues( + self, + jql: str, + limit: int = 50, + *, + next_page_token: str | None = None, + start_at: int = 0, + fields: list[str] | None = None, + ) -> dict[str, Any]: + """Search issues with JQL.""" + body: dict[str, Any] = { + "jql": jql or _DEFAULT_SEARCH_JQL, + "maxResults": self._normalize_limit(limit), + "fields": fields or list(_DEFAULT_SEARCH_FIELDS), + } + if self._is_data_center: + body["startAt"] = max(start_at, 0) + payload = self._request("POST", "/search", json=body) + return self._expect_object(payload, "issue.search") + 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._text_document_payload(description) + if assignee_account_id: + fields["assignee"] = self._user_identity_payload(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._text_document_payload(description) + ) + if assignee_account_id is not _UNSET: + fields["assignee"] = ( + None + if assignee_account_id is None + else self._user_identity_payload(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.""" + normalized_limit = self._normalize_limit(limit) + offset = max(start_at, 0) + if self._api_version == "2": + return self._get_issue_changelog_v2(issue_key, normalized_limit, offset) + payload = self._request( + "GET", + f"/issue/{issue_key}/changelog", + params={"maxResults": normalized_limit, "startAt": offset}, + ) + 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._text_document_payload(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._text_document_payload(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 or Data Center username.""" + payload = self._request("GET", "/user", params=self._user_identity_params(account_id)) + return self._expect_object(payload, "user") + + def create_user( + self, + email_address: str, + *, + username: str | None = None, + 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 self._is_data_center: + payload["name"] = username or email_address + if display_name: + payload["displayName"] = display_name + if products and not self._is_data_center: + 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 or Data Center username.""" + self._request( + "DELETE", "/user", params=self._user_identity_params(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 _get_issue_changelog_v2(self, issue_key: str, limit: int, start_at: int) -> dict[str, Any]: + payload = self._request( + "GET", + f"/issue/{issue_key}", + params={"expand": "changelog", "fields": "none"}, + ) + issue = self._expect_object(payload, "issue") + changelog = issue.get("changelog") + if not isinstance(changelog, dict): + raise ValueError("Jira API returned an invalid issue.changelog payload") + histories = changelog.get("histories") + if not isinstance(histories, list): + raise ValueError("Jira API returned an invalid issue.changelog payload") + normalized = [history for history in histories if isinstance(history, dict)] + total = int(changelog.get("total") or len(normalized)) + page = normalized[start_at : start_at + limit] + return { + "histories": page, + "startAt": start_at, + "maxResults": limit, + "total": total, + "isLast": start_at + limit >= total, + } + + 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("/")) + + @property + def _is_data_center(self) -> bool: + return self._deployment == "data_center" + + def _user_identity_payload(self, value: str) -> dict[str, str]: + if self._is_data_center: + return {"name": value} + return {"accountId": value} + + def _user_identity_params(self, value: str) -> dict[str, str]: + if self._is_data_center: + return {"username": value} + return {"accountId": value} + + def _text_document_payload(self, text: str) -> str | dict[str, Any]: + if self._api_version == "2": + return text + return self._adf_text_document(text) + + @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_deployment(config: dict[str, Any]) -> str: + raw_deployment = str(config.get("deployment", "") or "").strip().lower().replace("-", "_") + if raw_deployment in {"data_center", "datacenter", "server", "self_hosted"}: + return "data_center" + if raw_deployment == "cloud": + return "cloud" + if not raw_deployment and str(config.get("api_version", "") or "").strip() == "2": + return "data_center" + return _DEFAULT_DEPLOYMENT + + @staticmethod + def _normalize_api_version(config: dict[str, Any], deployment: str) -> str: + if deployment == "data_center": + return "2" + return str(config.get("api_version") or _DEFAULT_API_VERSION).strip() + + @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 _expect_project_list(payload: Any) -> list[dict[str, Any]]: + if not isinstance(payload, list): + raise ValueError("Jira API returned an invalid project list payload") + return [project for project in payload if isinstance(project, dict)] + + @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..a6a56435 --- /dev/null +++ b/backend/app/services/node_execution/nodes/jira_node.py @@ -0,0 +1,459 @@ +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(), + start_at=_start_at(), + 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()) + values = result.get("values") + if not isinstance(values, list): + values = result.get("histories") + histories = values if isinstance(values, 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, + username=_field("jiraUsername") or None, + 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]: + if "total" in result or "startAt" in result: + return _pagination(result) + 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 2293ed78..51b218cc 100644 --- a/backend/app/services/workflow_dsl_prompt.py +++ b/backend/app/services/workflow_dsl_prompt.py @@ -696,7 +696,7 @@ } ``` -### 4. agent (AI Agent with Tool Calling) +### 5. agent (AI Agent with Tool Calling) - **Purpose**: LLM with optional user-defined Python tools and/or MCP (Model Context Protocol) connections. The LLM can call tools to perform computations, then use results in its response. - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -1012,14 +1012,14 @@ } ``` -### 5. condition (If/Else Branch) +### 6. condition (If/Else Branch) - **Purpose**: Branch workflow based on condition - **Inputs**: 1 | **Outputs**: 2 (true path: output-0, false path: output-1) - **Data fields**: - `label`: Node identifier - `condition`: JavaScript-like expression (e.g., `$previousNodeLabel.text.length > 0`) -### 6. switch (Multi-way Branch) +### 7. switch (Multi-way Branch) - **Purpose**: Route to different paths based on value matching - **Inputs**: 1 | **Outputs**: N+1 (N cases + default) - **Data fields**: @@ -1027,7 +1027,7 @@ - `expression`: Value to evaluate (e.g., `$previousNodeLabel.category`) - `cases`: Array of case values ["case1", "case2"] -### 7. execute (Call Another Workflow) +### 8. execute (Call Another Workflow) - **Purpose**: Call/execute another workflow (sub-workflow) - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -1156,7 +1156,7 @@ - Each mapping's `value` should be an expression like `$nodeName.field` - **When target workflow needs multiple inputs, add corresponding `inputFields` to your textInput node!** -### 8. output (End Point with Optional Async Continuation) +### 9. output (End Point with Optional Async Continuation) - **Purpose**: Final output of the workflow, returns response to caller - **Inputs**: 1 | **Outputs**: 1 (optional, for async post-processing) - **Data fields**: @@ -1207,14 +1207,14 @@ - If multiple terminal nodes produce outputs (e.g. `output` + `jsonOutputMapper`, or two mappers), the runtime uses the normal per-label map — **unwrapping applies only when the sole terminal is one `jsonOutputMapper`**. - Same loop restriction as `output`: **never** place `jsonOutputMapper` inside a loop iteration branch (only after `done`). -### 9. wait (Delay) +### 10. wait (Delay) - **Purpose**: Pause execution for specified time - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: - `label`: Node identifier - `duration`: Milliseconds to wait (e.g., 1000 for 1 second) -### 10. http (HTTP Request) - CAN BE A STARTING POINT! +### 11. http (HTTP Request) - CAN BE A STARTING POINT! - **Purpose**: Make HTTP requests using cURL syntax - **Inputs**: 0 or 1 | **Outputs**: 1 - **⚠️ STARTING POINT**: HTTP nodes can START a workflow without any preceding node! Perfect for fetching static URLs without user input. @@ -1246,7 +1246,7 @@ - `$httpNodeLabel.body.fieldName` - Access parsed JSON fields from body - `$httpNodeLabel.headers` - Response headers object -### 11. merge (Combine Inputs) - USE ONLY WHEN EXPLICITLY REQUESTED! +### 12. merge (Combine Inputs) - USE ONLY WHEN EXPLICITLY REQUESTED! - **Purpose**: Wait for multiple parallel branches and combine their outputs - **Inputs**: N (configurable) | **Outputs**: 1 - **Data fields**: @@ -1258,7 +1258,7 @@ - DO NOT use merge for simple parallel workflows that end with separate outputs - If user wants parallel operations with separate results, use separate output nodes instead -### 12. set (Data Transformation) ⭐ USE THIS FOR TRANSFORMATIONS! +### 13. set (Data Transformation) ⭐ USE THIS FOR TRANSFORMATIONS! - **Purpose**: Transform input data - uppercase, lowercase, substring, concatenation, random numbers, etc. - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -1285,14 +1285,14 @@ 3. Functions return their native type - randomInt returns a number, no conversion needed 4. For modulo/math on set node output: `$generateRandom.randomNumber % 2 == 0` (use the key name, e.g., "randomNumber") -### 13. sticky (Note) +### 14. sticky (Note) - **Purpose**: Add documentation notes to canvas (not executed) - **Inputs**: 0 | **Outputs**: 0 - **Data fields**: - `label`: Node identifier - `note`: Markdown text content -### 14. errorHandler (Global Error Catcher) - ONLY ADD WHEN EXPLICITLY REQUESTED! +### 15. errorHandler (Global Error Catcher) - ONLY ADD WHEN EXPLICITLY REQUESTED! - **Purpose**: Catches errors from ANY node in the workflow. If present on canvas, executes when any node fails. - **Inputs**: 0 (triggered automatically on error) | **Outputs**: 1 - **Data fields**: @@ -1401,7 +1401,7 @@ } ``` -### 15. slack (Send Slack Message) +### 16. slack (Send Slack Message) - **Purpose**: Send a message to Slack channel via webhook - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -1452,7 +1452,7 @@ } ``` -### 16. sendEmail (Send Email via SMTP) +### 17. sendEmail (Send Email via SMTP) - **Purpose**: Send an email using SMTP credentials - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -1513,7 +1513,7 @@ } ``` -### 17. variable (Set Variable) +### 18. variable (Set Variable) - **Purpose**: Set or update a variable that can be accessed from ANY node via `$vars.variableName` (workflow-local) or `$global.variableName` (persistent, user-scoped) - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -1646,7 +1646,7 @@ 2. **Inside loop body**: Use another `variable` node with `.add()` to append items 3. **After loop (done branch)**: Access collected items via `$vars.variableName` -### 18. loop (Iterate Over Array) +### 19. loop (Iterate Over Array) - **Purpose**: Iterate over an array of items, executing downstream nodes for each item - **Inputs**: 2 handles - Default input (receives initial array data) @@ -1743,7 +1743,7 @@ - The workflow validator will REJECT any workflow with output nodes in loop branches - Use `set` or `variable` nodes for ALL intermediate processing inside loops -### 19. disableNode (Disable Another Node) +### 20. disableNode (Disable Another Node) - **Purpose**: Disable another node in the workflow permanently. When executed, sets the target node's `active` to `false` and updates the workflow in the database. - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -1788,7 +1788,7 @@ 5. If false: output says still waiting 6. After disable runs, the cron node's `active` becomes `false` and it won't trigger anymore -### 20. redis (Redis Operations) +### 21. redis (Redis Operations) - **Purpose**: Perform Redis operations (set, get, check key existence, delete) - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -1886,7 +1886,7 @@ - `$redisNodeLabel.key` - The key that was operated on - `$redisNodeLabel.ttl` - TTL value (for set operation with TTL) -### 21. rag (Vector Store / RAG Operations) +### 22. rag (Vector Store / RAG Operations) - **Purpose**: Insert documents into or search documents from a vector store (Qdrant or Postgres/pgvector) for RAG (Retrieval Augmented Generation) - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -1990,7 +1990,7 @@ } ``` -### 22. grist (Grist Spreadsheet Operations) +### 23. grist (Grist Spreadsheet Operations) - **Purpose**: Read, write, and manage data in Grist spreadsheets (open-source spreadsheet platform) - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -2173,7 +2173,7 @@ - `$gristNodeLabel.tables` - Array of tables (for listTables) - `$gristNodeLabel.columns` - Array of columns with id and label (use listColumns to discover column IDs) -### 23. googleSheets (Google Sheets Operations) +### 24. googleSheets (Google Sheets Operations) - **Purpose**: Read, write, append, clear, and inspect Google Sheets spreadsheets via OAuth2 - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -2305,7 +2305,7 @@ - `$sheetInfo.title` — Spreadsheet title - `$sheetInfo.sheets` — Array of {sheetId, title} objects -### 24. bigquery (Google BigQuery Operations) +### 25. bigquery (Google BigQuery Operations) - **Purpose**: Run SQL queries against Google BigQuery data warehouses and stream-insert rows into tables via OAuth2 - **Inputs**: 1 | **Outputs**: 1 - **Credential required**: `bigquery` credential type (OAuth2 — client_id + client_secret, then Connect via browser popup) @@ -2597,7 +2597,7 @@ - `$queryTasks.count` — result count - `$createTask.success` — boolean success indicator -### 25. dataTable (DataTable Operations) +### 26. dataTable (DataTable Operations) - **Purpose**: Read, write, and manage data in Heym DataTables (first-party structured storage) - **Inputs**: 1 | **Outputs**: 1 - **No credential required** — DataTable operates on internal Heym database @@ -2690,7 +2690,7 @@ - `$nodeLabel.found` - Boolean (for getById) - `$nodeLabel.operation` - Operation name (e.g. "count"; "insert"/"update" for upsert) -### 24. throwError (Stop Workflow with Error) +### 27. throwError (Stop Workflow with Error) - **Purpose**: Immediately stop workflow execution and return an error response with a custom HTTP status code - **Inputs**: 1 | **Outputs**: 0 (workflow stops here) - **Data fields**: @@ -2765,7 +2765,7 @@ } ``` -### 25. rabbitmq (RabbitMQ Message Queue) +### 28. rabbitmq (RabbitMQ Message Queue) - **Purpose**: Send or receive messages from RabbitMQ queues and exchanges - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -2878,7 +2878,7 @@ } ``` -### 26. crawler (Web Scraping with FlareSolverr) +### 29. crawler (Web Scraping with FlareSolverr) - **Purpose**: Scrape web pages using FlareSolverr with optional HTML extraction via CSS selectors - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -3018,7 +3018,7 @@ ``` This returns: `["news/my-article.html", "blog/another-post.html", ...]` -### 27. consoleLog (Backend Console Log) +### 30. consoleLog (Backend Console Log) - **Purpose**: Writes a value to the backend (server) console for debugging. Logs do NOT appear in the browser. - **⛔ WHEN TO CREATE**: Add this node **ONLY** when the user explicitly requests backend/console logging. If this intent is NOT present, do **NOT** create any consoleLog node. - **Inputs**: 1 | **Outputs**: 1 @@ -3031,7 +3031,7 @@ { "type": "consoleLog", "data": { "label": "debugStep", "logMessage": "$userInput.body.text" } } ``` -### 28. playwright (Browser Automation) +### 31. playwright (Browser Automation) - **Purpose**: Execute browser automation steps (navigate, click, type, fill, etc.) using Playwright. Steps are defined in the node and executed in order at runtime. Supports optional auth bootstrap from cookies/storageState plus fallback login steps. - **Inputs**: 1 | **Outputs**: 1 - **Data fields**: @@ -3146,7 +3146,7 @@ **Note**: Add steps in the Properties Panel. Steps are executed in order at runtime. Execution requires at least one step in `playwrightSteps`. If `playwrightAuthEnabled` is true, the first item in `playwrightSteps` must be `navigate`, auth bootstrap works only with step-based execution, and `playwrightAuthFallbackSteps` should leave the browser on the authenticated page expected by the remaining main steps. -### 29. drive (Drive File Management) +### 32. drive (Drive File Management) - **Purpose**: List and manage files generated by skills — retrieve metadata, delete, password-protect, set expiry (TTL), limit downloads, or share/unshare with teams - **Inputs**: 1 | **Outputs**: 1 - **No credential required** — operates on files owned by the workflow owner @@ -3334,7 +3334,7 @@ - `$agentLabel._generated_files[0].mime_type` - MIME type - `$agentLabel._generated_files[0].size_bytes` - File size in bytes -### 30. mcpCall (Direct MCP Tool Call) +### 33. mcpCall (Direct MCP Tool Call) - **Purpose**: Call a specific MCP tool directly without an LLM deciding which tool to use - **Inputs**: 1 | **Outputs**: 1 - **WHEN TO USE**: When you know exactly which MCP tool to call. Deterministic — no LLM loop. @@ -3368,7 +3368,7 @@ **Downstream access:** - `$searchCall.result` → tool result (object or string) -### 31. chartOutput (Dashboard Widget Chart) - TERMINAL NODE FOR DASHBOARD WIDGETS +### 34. chartOutput (Dashboard Widget Chart) - TERMINAL NODE FOR DASHBOARD WIDGETS - **Purpose**: Terminal node that turns upstream rows into a chart for a dashboard widget. - **Inputs**: 1 | **Outputs**: 0 (it is the LAST node in a dashboard-widget workflow) - **WHEN TO USE**: Only in dashboard-widget workflows. The node before it must produce an array of @@ -3836,7 +3836,7 @@ **If a function is NOT in the documentation above, it DOES NOT EXIST!** Use ONLY: `str()`, `int()`, `float()`, `bool()`, `list()`, `dict(key=value)`, `len()`, `abs()`, `min()`, `max()`, `round()`, `sum()`, `sorted()`, `randomInt()`, `range()`, `array()`, `notNull()`, `upper()`, `lower()`, `strip()`, `capitalize()`, `title()`, `split()`, `join()`, `replace()`, `regexReplace()`, `hash()`, and the documented string/array/object methods. -### 32. s3 (Amazon S3 Operations) +### 35. s3 (Amazon S3 Operations) - **Type**: `s3` - **Purpose**: Manage buckets and folders; list, upload, download, copy, and delete objects in Amazon S3 - **Inputs**: 1 | **Outputs**: 1 @@ -3931,7 +3931,7 @@ } ``` -### 33. linear (Linear Workspace and Issue Operations) +### 36. linear (Linear Workspace and Issue Operations) - **Type**: `linear` - **Inputs**: 1, **Outputs**: 1 - **Credential required**: `linear` credential type containing a Linear personal API key or @@ -3990,7 +3990,7 @@ } ``` -### 34. sentry (Sentry REST Operations) +### 37. sentry (Sentry REST Operations) - **Type**: `sentry` - **Purpose**: Manage Sentry organizations, projects, teams, issues, events, and releases - **Inputs**: 1 | **Outputs**: 1 @@ -4037,7 +4037,97 @@ } ``` -### 35. github (GitHub REST Operations) +### 38. 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, Jira base URL, and deployment mode (`cloud` or `data_center`) +- **Operations**: + - `getMyself`: authenticated Jira user + - `listProjects`: projects visible to the user + - `searchIssues`: search issues with `jiraJql`; Cloud uses `/search/jql` and + `jiraNextPageToken`, Data Center uses `/search` and `jiraStartAt`; defaults to + `updated >= -30d ORDER BY updated DESC`; optional `jiraFields` (defaults to + `key`, `summary`, `status`, `assignee`, `issuetype`) + - `getIssue`: fetch by key or ID such as `ENG-123` + - `createIssue`: requires `jiraProjectKey` and `jiraSummary`; optional `jiraIssueType`, + `jiraIssueTypeId`, `jiraDescription`, `jiraAssigneeAccountId` (Cloud accountId or + Data Center username), 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` (Cloud accountId or Data Center username) + - `createUser`: requires `jiraUserEmail`; optional `jiraUsername`, `jiraUserDisplayName`, + and `jiraUserProducts` (admin permissions; username applies to Data Center, products + apply to Jira Cloud) + - `deleteUser`: requires `jiraAccountId` (Cloud accountId or Data Center username; admin permissions) +- **Fields**: `jiraOperation`, `jiraProjectKey`, `jiraIssueKey`, `jiraIssueType`, + `jiraIssueTypeId`, `jiraSummary`, `jiraDescription`, `jiraJql`, `jiraFields`, + `jiraAssigneeAccountId`, `jiraLabels`, `jiraCommentBody`, `jiraCommentId`, + `jiraTransitionId`, `jiraNotifySubject`, `jiraAttachmentId`, `jiraAttachmentFilename`, + `jiraAttachmentBase64`, `jiraAttachmentMimeType`, + `jiraNotifyTextBody`, `jiraNotifyHtmlBody`, `jiraNotifyTo`, `jiraAccountId`, + `jiraUsername`, `jiraUserEmail`, `jiraUserDisplayName`, `jiraUserProducts`, + `jiraLimit` (1-100), `jiraStartAt`, `jiraNextPageToken` +- Text fields support expressions. `jiraIncludeBinary` is a static boolean toggle and can be + marked as agent-provided when the Jira node is attached to an Agent tool handle. + `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` on Cloud and `pagination.startAt` on Data Center. +- Jira REST API v3 sends issue descriptions and comments as Atlassian Document Format (ADF); + Data Center REST API v2 sends plain text. `getIssueChangelog` uses + `/issue/{key}/changelog` on Cloud and expanded issue changelog data on Data Center. +- List outputs contain `{success, operation, count, projects|issues|comments|changelog|attachments, pagination}`. + `searchIssues` uses cursor pagination (`pagination.nextPageToken`) on Cloud and offset + pagination (`pagination.startAt`) on Data Center. 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\"]" + } +} +``` + +### 39. github (GitHub REST Operations) - **Type**: `github` - **Purpose**: Manage repositories, users, issues, pull requests, reviews, releases, Actions workflows, traffic insights, and repository files @@ -4110,7 +4200,7 @@ } ``` -### 36. codex (OpenAI Codex Coding Agent) +### 40. codex (OpenAI Codex Coding Agent) - **Type**: `codex` - **Purpose**: Run a coding task against a Git repository using the Codex CLI inside Heym's isolated runner. This node is access-token-only; do not use OpenAI API keys. @@ -4337,7 +4427,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 fa4173cd..1c400bef 100644 --- a/backend/tests/test_alembic_migrations.py +++ b/backend/tests/test_alembic_migrations.py @@ -15,7 +15,12 @@ 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(), ["094_add_file_team_shares"]) + self.assertEqual(self.script.get_heads(), ["096_merge_jira_file_heads"]) + + def test_revision_ids_fit_default_alembic_version_column(self) -> None: + for revision in self.script.walk_revisions(): + with self.subTest(revision=revision.revision): + self.assertLessEqual(len(revision.revision), 32) def test_plugins_revision_follows_workflow_timeout(self) -> None: plugins_revision = self.script.get_revision("090_add_plugins_table") @@ -41,12 +46,27 @@ 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("095_add_jira_credential_type") + + self.assertIsNotNone(jira_revision) + self.assertEqual(jira_revision.down_revision, "093_add_codex_node_support") + def test_file_team_shares_revision_follows_codex_revision(self) -> None: file_team_shares_revision = self.script.get_revision("094_add_file_team_shares") self.assertIsNotNone(file_team_shares_revision) self.assertEqual(file_team_shares_revision.down_revision, "093_add_codex_node_support") + def test_jira_and_file_team_share_heads_are_merged(self) -> None: + merge_revision = self.script.get_revision("096_merge_jira_file_heads") + + self.assertIsNotNone(merge_revision) + self.assertEqual( + set(merge_revision.down_revision), + {"095_add_jira_credential_type", "094_add_file_team_shares"}, + ) + 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_build_assistant_prompt_node_templates.py b/backend/tests/test_build_assistant_prompt_node_templates.py index f1d93266..eba11b87 100644 --- a/backend/tests/test_build_assistant_prompt_node_templates.py +++ b/backend/tests/test_build_assistant_prompt_node_templates.py @@ -70,7 +70,7 @@ def test_includes_websocket_guidance(self) -> None: def test_includes_amazon_s3_guidance(self) -> None: prompt = build_assistant_prompt() - self.assertIn("### 32. s3 (Amazon S3 Operations)", prompt) + self.assertIn("### 35. s3 (Amazon S3 Operations)", prompt) self.assertIn('"type": "s3"', prompt) self.assertIn("s3Operation", prompt) self.assertIn("s3ContentType", prompt) @@ -84,7 +84,7 @@ def test_includes_amazon_s3_guidance(self) -> None: def test_includes_github_guidance(self) -> None: prompt = build_assistant_prompt() - self.assertIn("### 35. github (GitHub REST Operations)", prompt) + self.assertIn("### 39. github (GitHub REST Operations)", prompt) self.assertIn('"type": "github"', prompt) self.assertIn("githubOperation", prompt) self.assertIn("createIssue", prompt) @@ -95,7 +95,7 @@ def test_includes_github_guidance(self) -> None: def test_includes_sentry_guidance(self) -> None: prompt = build_assistant_prompt() - self.assertIn("### 34. sentry (Sentry REST Operations)", prompt) + self.assertIn("### 37. sentry (Sentry REST Operations)", prompt) self.assertIn('"type": "sentry"', prompt) self.assertIn("sentryOperation", prompt) self.assertIn("listIssues", prompt) @@ -105,7 +105,7 @@ def test_includes_sentry_guidance(self) -> None: def test_includes_linear_guidance(self) -> None: prompt = build_assistant_prompt() - self.assertIn("### 33. linear (Linear Workspace and Issue Operations)", prompt) + self.assertIn("### 36. linear (Linear Workspace and Issue Operations)", prompt) self.assertIn('"type": "linear"', prompt) self.assertIn("linearOperation", prompt) self.assertIn("linearTeamId", prompt) diff --git a/backend/tests/test_jira_node.py b/backend/tests/test_jira_node.py new file mode 100644 index 00000000..f91cc4b5 --- /dev/null +++ b/backend/tests/test_jira_node.py @@ -0,0 +1,1290 @@ +"""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_sends_plain_text_description_for_api_v2(self) -> None: + client = MagicMock() + client.request.return_value = _response( + {"id": "10001", "key": "ENG-1"}, + method="POST", + url="https://example.atlassian.net/rest/api/2/issue", + ) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + "api_version": "2", + }, + client=client, + ) + + service.create_issue("ENG", "Bug", "Fix checkout", description="Checkout is broken") + + fields = client.request.call_args.kwargs["json"]["fields"] + self.assertEqual(fields["description"], "Checkout is broken") + + def test_create_issue_uses_username_assignee_for_data_center(self) -> None: + client = MagicMock() + client.request.return_value = _response( + {"id": "10001", "key": "ENG-1"}, + method="POST", + url="https://jira.example.com/rest/api/2/issue", + ) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://jira.example.com", + "deployment": "data_center", + }, + client=client, + ) + + service.create_issue("ENG", "Bug", "Fix checkout", assignee_account_id="ada") + + fields = client.request.call_args.kwargs["json"]["fields"] + self.assertEqual(fields["assignee"], {"name": "ada"}) + + def test_list_projects_uses_array_endpoint_for_data_center(self) -> None: + client = MagicMock() + client.request.return_value = _response( + [ + {"id": "10000", "key": "ENG"}, + {"id": "10001", "key": "OPS"}, + {"id": "10002", "key": "DOCS"}, + ], + url="https://jira.example.com/rest/api/2/project", + ) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://jira.example.com", + "deployment": "data_center", + }, + client=client, + ) + + result = service.list_projects(limit=1, start_at=1) + + self.assertEqual(result["values"], [{"id": "10001", "key": "OPS"}]) + self.assertEqual(result["startAt"], 1) + self.assertEqual(result["maxResults"], 1) + self.assertEqual(result["total"], 3) + self.assertFalse(result["isLast"]) + self.assertEqual( + client.request.call_args.args[:2], + ("GET", "https://jira.example.com/rest/api/2/project"), + ) + self.assertIsNone(client.request.call_args.kwargs["params"]) + + 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_search_issues_uses_offset_search_for_data_center(self) -> None: + client = MagicMock() + client.request.return_value = _response( + {"issues": [{"id": "10001", "key": "ENG-1"}], "startAt": 10, "total": 11}, + method="POST", + url="https://jira.example.com/rest/api/2/search", + ) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://jira.example.com", + "deployment": "data_center", + }, + client=client, + ) + + result = service.search_issues( + "project = ENG", + 25, + next_page_token="ignored-for-data-center", + start_at=10, + fields=["key", "summary"], + ) + + self.assertEqual(result["total"], 11) + self.assertEqual( + client.request.call_args.args[:2], + ("POST", "https://jira.example.com/rest/api/2/search"), + ) + self.assertEqual( + client.request.call_args.kwargs["json"], + { + "jql": "project = ENG", + "maxResults": 25, + "fields": ["key", "summary"], + "startAt": 10, + }, + ) + + 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({"values": [{"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_get_issue_changelog_uses_expanded_issue_for_api_v2(self) -> None: + client = MagicMock() + client.request.return_value = _response( + { + "changelog": { + "histories": [{"id": "10001"}, {"id": "10002"}, {"id": "10003"}], + "total": 3, + } + } + ) + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + "api_version": "2", + }, + client=client, + ) + + result = service.get_issue_changelog("ENG-1", limit=1, start_at=1) + + self.assertEqual(result["histories"], [{"id": "10002"}]) + self.assertEqual(result["total"], 3) + self.assertFalse(result["isLast"]) + self.assertEqual( + client.request.call_args.args[:2], + ("GET", "https://example.atlassian.net/rest/api/2/issue/ENG-1"), + ) + self.assertEqual( + client.request.call_args.kwargs["params"], + {"expand": "changelog", "fields": "none"}, + ) + + 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_update_comment_sends_plain_text_body_for_api_v2(self) -> None: + client = MagicMock() + client.request.return_value = _response({"id": "10001", "body": "Updated"}, method="PUT") + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://example.atlassian.net", + "api_version": "2", + }, + client=client, + ) + + service.update_comment("ENG-1", "10001", "Updated") + + self.assertEqual(client.request.call_args.kwargs["json"]["body"], "Updated") + + 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", + username="ada", + 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_get_and_delete_user_use_username_for_data_center(self) -> None: + client = MagicMock() + client.request.side_effect = [ + _response({"name": "ada"}, url="https://jira.example.com/rest/api/2/user"), + _response(None, status_code=204, method="DELETE"), + ] + service = JiraService( + { + "email": "ada@example.com", + "api_token": "jira-token", + "base_url": "https://jira.example.com", + "deployment": "data_center", + }, + client=client, + ) + + user = service.get_user("ada") + deleted = service.delete_user("ada") + + self.assertEqual(user["name"], "ada") + self.assertTrue(deleted) + self.assertEqual(client.request.call_args_list[0].kwargs["params"], {"username": "ada"}) + self.assertEqual(client.request.call_args_list[1].kwargs["params"], {"username": "ada"}) + + def test_create_user_sends_data_center_payload(self) -> None: + client = MagicMock() + client.request.return_value = _response({"name": "ada"}, method="POST") + service = JiraService( + { + "email": "admin@example.com", + "api_token": "jira-token", + "base_url": "https://jira.example.com", + "deployment": "data_center", + }, + client=client, + ) + + user = service.create_user( + "ada@example.com", + username="ada", + display_name="Ada", + products=["jira-software"], + ) + + self.assertEqual(user["name"], "ada") + self.assertEqual( + client.request.call_args.args[:2], + ("POST", "https://jira.example.com/rest/api/2/user"), + ) + self.assertEqual( + client.request.call_args.kwargs["json"], + {"emailAddress": "ada@example.com", "name": "ada", "displayName": "Ada"}, + ) + + 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", + "jiraUsername": "$input.username", + "jiraUserDisplayName": "Ada", + "jiraUserProducts": '["jira-software"]', + }, + {"email": "ada@example.com", "username": "ada"}, + ) + 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", + username="ada", + 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_values(self) -> None: + ctx = _make_ctx( + { + "credentialId": "cred-1", + "jiraOperation": "getIssueChangelog", + "jiraIssueKey": "ENG-1", + } + ) + mock_service = MagicMock() + mock_service.get_issue_changelog.return_value = { + "values": [{"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", + start_at=0, + 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, + start_at=0, + 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..31e247dc --- /dev/null +++ b/frontend/e2e/jira-node-properties.spec.ts @@ -0,0 +1,235 @@ +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(); + await expect(panel.getByText("Start At", { 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); + } +}); + +test("shows Jira create user identity fields", async ({ page }) => { + const workflow = await createWorkflow( + page, + `Jira Create User Properties ${Date.now()}`, + [ + { + id: "jira-node", + type: "jira", + position: { x: 120, y: 160 }, + data: { + label: "jira", + credentialId: "", + jiraOperation: "createUser", + jiraUserEmail: "ada@example.com", + jiraUsername: "ada", + jiraUserDisplayName: "Ada Lovelace", + }, + }, + ], + ); + + 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-user-email-field")).toBeVisible(); + await expect(panel.getByTestId("jira-username-field")).toBeVisible(); + await expect(panel.getByTestId("jira-user-display-name-field")).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..911c01ae 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: "", jiraUsername: "", 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 9787aeaa..32767d5a 100644 --- a/frontend/src/components/Credentials/CredentialDialog.vue +++ b/frontend/src/components/Credentials/CredentialDialog.vue @@ -48,6 +48,32 @@ const codexSigningIn = ref(false); const codexSignInError = ref(""); const codexSignedInAccount = ref(""); const baseUrl = ref(""); +const jiraEmail = ref(""); +const jiraDeployment = ref<"cloud" | "data_center">("cloud"); +const jiraApiVersion = ref("3"); +const jiraTesting = ref(false); +const jiraTestSuccess = ref(null); +const jiraTestMessage = ref(""); +const jiraUserLabel = computed((): string => + jiraDeployment.value === "data_center" ? "Jira Username" : "Jira Email", +); +const jiraUserPlaceholder = computed((): string => + jiraDeployment.value === "data_center" ? "jira-user" : "you@example.com", +); +const jiraSecretLabel = computed((): string => + jiraDeployment.value === "data_center" ? "Password" : "API Token", +); +const jiraSecretPlaceholder = computed((): string => { + if (isEditing.value) { + return "••••••• (re-enter to update)"; + } + return jiraDeployment.value === "data_center" ? "Jira password" : "Atlassian API token"; +}); +const jiraAuthHelpText = computed((): string => + jiraDeployment.value === "data_center" + ? "Data Center / Server currently supports Basic auth with username and password. Personal access tokens and Bearer auth are not supported yet." + : "Jira Cloud uses an Atlassian account email and API token.", +); const bearerToken = ref(""); const headerKey = ref(""); const headerValue = ref(""); @@ -188,6 +214,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 }, @@ -236,7 +263,23 @@ 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 ?? "" + : ""; + jiraDeployment.value = + props.credential.type === "jira" && + props.credential.public_fields?.deployment === "data_center" + ? "data_center" + : "cloud"; + jiraApiVersion.value = + props.credential.type === "jira" + ? props.credential.public_fields?.api_version ?? "3" + : "3"; bearerToken.value = ""; headerKey.value = props.credential.header_key || ""; headerValue.value = ""; @@ -344,6 +387,9 @@ watch( resetCodexOAuthState(); codexSignedInAccount.value = ""; baseUrl.value = ""; + jiraEmail.value = ""; + jiraDeployment.value = "cloud"; + jiraApiVersion.value = "3"; bearerToken.value = ""; headerKey.value = ""; headerValue.value = ""; @@ -425,6 +471,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 = ""; @@ -460,6 +509,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; @@ -592,6 +646,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 = ""; @@ -656,6 +720,18 @@ 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(), + deployment: jiraDeployment.value, + }; + const apiVersion = jiraDeployment.value === "data_center" ? "2" : jiraApiVersion.value.trim(); + if (apiVersion) { + config.api_version = apiVersion; + } + return config; } else if (type.value === "sentry") { const trimmedBaseUrl = baseUrl.value.trim(); return { @@ -1021,6 +1097,47 @@ async function testLinearConnection(): Promise { } } +async function testJiraConnection(): Promise { + if (!canTestJiraConnection.value) { + error.value = + jiraDeployment.value === "data_center" + ? "Enter Jira username, password, and base URL to test the connection." + : "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(), + deployment: jiraDeployment.value, + api_version: jiraDeployment.value === "data_center" ? "2" : 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; @@ -1587,6 +1704,100 @@ async function handleSave(): Promise { + +