From 306581e70bc5e6736d6b70e8b3dfe56976a68cb6 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Sat, 11 Jul 2026 21:56:52 +0200 Subject: [PATCH] feat(jira): new Atlassian Jira integration Jira Cloud and On-Prem/Data Center (27 commands): JQL ingestion with an OCSF mapper and JQL query, full issue lifecycle (create/get/edit/delete, transitions, assign), comments, remote web links and issue links, attachments (upload/download/delete), field and user lookups, and agile boards/sprints/epics. Basic (email + API token) or Personal Access Token authentication; API v3 + ADF bodies on Cloud, v2 on On-Prem. Stdlib-only, no extra Python dependencies. Co-Authored-By: Claude Fable 5 --- .../jira/incident-types/jira-issue.yaml | 3 + integrations/jira/manifest.yaml | 367 ++++++++++++++++++ integrations/jira/mappers/get_incidents.yaml | 23 ++ integrations/jira/scripts/add_comment.py | 88 +++++ integrations/jira/scripts/add_link.py | 94 +++++ integrations/jira/scripts/assign_issue.py | 91 +++++ integrations/jira/scripts/board_issue_list.py | 119 ++++++ integrations/jira/scripts/board_list.py | 92 +++++ .../jira/scripts/board_sprint_list.py | 82 ++++ integrations/jira/scripts/create_issue.py | 151 +++++++ .../jira/scripts/delete_attachment.py | 84 ++++ integrations/jira/scripts/delete_comment.py | 86 ++++ integrations/jira/scripts/delete_issue.py | 80 ++++ integrations/jira/scripts/edit_comment.py | 92 +++++ integrations/jira/scripts/edit_issue.py | 159 ++++++++ integrations/jira/scripts/epic_issue_list.py | 121 ++++++ integrations/jira/scripts/get_attachment.py | 98 +++++ integrations/jira/scripts/get_comments.py | 105 +++++ .../jira/scripts/get_id_by_attribute.py | 104 +++++ integrations/jira/scripts/get_incidents.py | 156 ++++++++ integrations/jira/scripts/get_issue.py | 117 ++++++ integrations/jira/scripts/get_link_types.py | 84 ++++ integrations/jira/scripts/get_user_info.py | 88 +++++ integrations/jira/scripts/link_issues.py | 110 ++++++ integrations/jira/scripts/list_fields.py | 79 ++++ integrations/jira/scripts/list_transitions.py | 84 ++++ integrations/jira/scripts/query_issues.py | 138 +++++++ .../jira/scripts/sprint_issue_list.py | 119 ++++++ integrations/jira/scripts/test_connection.py | 82 ++++ integrations/jira/scripts/upload_file.py | 111 ++++++ 30 files changed, 3207 insertions(+) create mode 100644 integrations/jira/incident-types/jira-issue.yaml create mode 100644 integrations/jira/manifest.yaml create mode 100644 integrations/jira/mappers/get_incidents.yaml create mode 100644 integrations/jira/scripts/add_comment.py create mode 100644 integrations/jira/scripts/add_link.py create mode 100644 integrations/jira/scripts/assign_issue.py create mode 100644 integrations/jira/scripts/board_issue_list.py create mode 100644 integrations/jira/scripts/board_list.py create mode 100644 integrations/jira/scripts/board_sprint_list.py create mode 100644 integrations/jira/scripts/create_issue.py create mode 100644 integrations/jira/scripts/delete_attachment.py create mode 100644 integrations/jira/scripts/delete_comment.py create mode 100644 integrations/jira/scripts/delete_issue.py create mode 100644 integrations/jira/scripts/edit_comment.py create mode 100644 integrations/jira/scripts/edit_issue.py create mode 100644 integrations/jira/scripts/epic_issue_list.py create mode 100644 integrations/jira/scripts/get_attachment.py create mode 100644 integrations/jira/scripts/get_comments.py create mode 100644 integrations/jira/scripts/get_id_by_attribute.py create mode 100644 integrations/jira/scripts/get_incidents.py create mode 100644 integrations/jira/scripts/get_issue.py create mode 100644 integrations/jira/scripts/get_link_types.py create mode 100644 integrations/jira/scripts/get_user_info.py create mode 100644 integrations/jira/scripts/link_issues.py create mode 100644 integrations/jira/scripts/list_fields.py create mode 100644 integrations/jira/scripts/list_transitions.py create mode 100644 integrations/jira/scripts/query_issues.py create mode 100644 integrations/jira/scripts/sprint_issue_list.py create mode 100644 integrations/jira/scripts/test_connection.py create mode 100644 integrations/jira/scripts/upload_file.py diff --git a/integrations/jira/incident-types/jira-issue.yaml b/integrations/jira/incident-types/jira-issue.yaml new file mode 100644 index 0000000..8440fda --- /dev/null +++ b/integrations/jira/incident-types/jira-issue.yaml @@ -0,0 +1,3 @@ +name: "Jira Issue" +color: "#0052cc" +icon: "alert" diff --git a/integrations/jira/manifest.yaml b/integrations/jira/manifest.yaml new file mode 100644 index 0000000..2554402 --- /dev/null +++ b/integrations/jira/manifest.yaml @@ -0,0 +1,367 @@ +id: jira +name: Jira +version: 1.0.0 +description: "Atlassian Jira (REST API, Cloud and On-Prem/Data Center) — issue lifecycle (create/read/edit/delete, transitions, assign), comments, web + issue links, attachments (upload/download/delete), field and user lookups, agile boards/sprints/epics, plus issue ingestion (get_incidents) with an OCSF mapper. Basic (email + API token) or Personal Access Token authentication. Stdlib-only, no extra Python dependencies." +changelog: "1.0.0 — Initial release: JQL ingestion + query, full issue CRUD and transitions, comments, links, attachments, fields/users, and agile board/sprint/epic listing. Cloud (API v3, ADF bodies) and On-Prem (API v2) supported." +category: ticketing + +# Per-instance configuration. For Jira Cloud set cloud_id (found at +# admin.atlassian.com/s//users) and keep the default server_url; for +# On-Prem/Data Center leave cloud_id empty and set server_url to your Jira URL. +# Authentication: Basic (username = account email + api_key = API token) OR a +# Personal Access Token (pat). Provide only one method. +config_schema: + properties: + server_url: + type: string + description: "Base URL. Jira Cloud: https://api.atlassian.com/ex/jira (default). On-Prem: your Jira server URL." + default: https://api.atlassian.com/ex/jira + cloud_id: + type: string + description: "Jira Cloud ID (leave empty for On-Prem/Data Center)" + username: + type: string + description: "Account email (Basic auth)" + api_key: + type: string + description: "API token (Basic auth)" + x-soar-sensitive: true + pat: + type: string + description: "Personal Access Token (use instead of username/api_key)" + x-soar-sensitive: true + required: + - server_url + +commands: + # ── Ingestion / search ──────────────────────────────────────────────────── + - id: get_incidents + name: jira-get-incidents + description: "Fetch Jira issues matching a JQL query for ingestion. Returns {result:[...flattened issues...]}; use result as the alert rule results path." + risk: read + inputs_schema: + properties: + query: { type: string, description: "JQL query (e.g. status != Done). Do not add an ORDER BY — the fetch adds one." } + after: { type: string, description: "Lower bound on created time, ISO8601 or epoch (incremental fetch watermark, applied as created >= ...)" } + max: { type: number, description: "Maximum issues to fetch (default 50)" } + required: [] + outputs_schema: { properties: {} } + ingest: + results_path: result + dedup_key: id + incremental_field: after + - id: query_issues + name: jira-query-issues + description: "Run a JQL query and return flattened issues." + risk: read + inputs_schema: + properties: + query: { type: string, description: "JQL query string" } + max_results: { type: number, description: "Maximum issues (default 50)" } + next_page_token: { type: string, description: "Token for the next page (Jira Cloud only)" } + start_at: { type: number, description: "Index of the first issue (On-Prem; Cloud uses next_page_token)" } + required: [query] + outputs_schema: { properties: {} } + - id: get_issue + name: jira-get-issue + description: "Retrieve a single issue by ID or key (flattened)." + risk: read + inputs_schema: + properties: + issue_id: { type: string, description: "Issue ID (issue_id or issue_key required)" } + issue_key: { type: string, description: "Issue key, e.g. PROJ-123 (issue_id or issue_key required)" } + required: [] + outputs_schema: { properties: {} } + + # ── Issue lifecycle ─────────────────────────────────────────────────────── + - id: create_issue + name: jira-create-issue + description: "Create a new issue. Provide project_key (or project_name), issue_type_name (or issue_type_id) and a summary." + inputs_schema: + properties: + project_key: { type: string, description: "Project key (project_key or project_name required)" } + project_name: { type: string, description: "Project name (resolved to a key; project_key or project_name required)" } + issue_type_name: { type: string, description: "Issue type name, e.g. Task, Bug (issue_type_name or issue_type_id required)" } + issue_type_id: { type: string, description: "Issue type ID (issue_type_name or issue_type_id required)" } + summary: { type: string, description: "Issue summary (required)" } + description: { type: string, description: "Issue description" } + priority: { type: string, description: "Priority name, e.g. High, Medium" } + labels: { type: string, description: "Comma-separated labels" } + components: { type: string, description: "Comma-separated component names (must already exist)" } + assignee_id: { type: string, description: "Assignee account ID (Cloud)" } + assignee: { type: string, description: "Assignee username (On-Prem)" } + due_date: { type: string, description: "Due date (yyyy-mm-dd)" } + fields_json: { type: string, description: "Raw fields JSON merged into the request (advanced; wins on key conflicts)" } + required: [summary] + outputs_schema: { properties: {} } + - id: edit_issue + name: jira-edit-issue + description: "Edit an issue's fields, and/or move it with status/transition (see jira-list-transitions)." + inputs_schema: + properties: + issue_id: { type: string, description: "Issue ID (issue_id or issue_key required)" } + issue_key: { type: string, description: "Issue key (issue_id or issue_key required)" } + summary: { type: string, description: "New summary" } + description: { type: string, description: "New description" } + priority: { type: string, description: "Priority name" } + labels: { type: string, description: "Comma-separated labels (replaces existing)" } + components: { type: string, description: "Comma-separated component names (replaces existing)" } + assignee_id: { type: string, description: "Assignee account ID (Cloud)" } + assignee: { type: string, description: "Assignee username (On-Prem)" } + due_date: { type: string, description: "Due date (yyyy-mm-dd)" } + status: { type: string, description: "Target status name — transitions the issue (mutually exclusive with transition)" } + transition: { type: string, description: "Transition name to apply (mutually exclusive with status)" } + fields_json: { type: string, description: "Raw fields JSON merged into the request (advanced)" } + required: [] + outputs_schema: { properties: {} } + - id: delete_issue + name: jira-delete-issue + description: "Delete an issue (its sub-tasks are deleted too)." + inputs_schema: + properties: + issue_id: { type: string, description: "Issue ID (issue_id or issue_key required)" } + issue_key: { type: string, description: "Issue key (issue_id or issue_key required)" } + required: [] + outputs_schema: { properties: {} } + - id: assign_issue + name: jira-assign-issue + description: "Assign an issue to a user (assignee_id for Cloud, assignee for On-Prem)." + inputs_schema: + properties: + issue_id: { type: string, description: "Issue ID (issue_id or issue_key required)" } + issue_key: { type: string, description: "Issue key (issue_id or issue_key required)" } + assignee_id: { type: string, description: "Assignee account ID (Cloud)" } + assignee: { type: string, description: "Assignee username (On-Prem)" } + required: [] + outputs_schema: { properties: {} } + - id: list_transitions + name: jira-list-transitions + description: "List the transitions available for an issue in its current status." + risk: read + inputs_schema: + properties: + issue_id: { type: string, description: "Issue ID (issue_id or issue_key required)" } + issue_key: { type: string, description: "Issue key (issue_id or issue_key required)" } + required: [] + outputs_schema: { properties: {} } + + # ── Comments ────────────────────────────────────────────────────────────── + - id: add_comment + name: jira-add-comment + description: "Add a comment to an issue." + inputs_schema: + properties: + issue_id: { type: string, description: "Issue ID (issue_id or issue_key required)" } + issue_key: { type: string, description: "Issue key (issue_id or issue_key required)" } + comment: { type: string, description: "Comment body" } + visibility: { type: string, description: "Restrict visibility to a role, e.g. Administrators" } + required: [comment] + outputs_schema: { properties: {} } + - id: get_comments + name: jira-get-comments + description: "Return the comments of an issue." + risk: read + inputs_schema: + properties: + issue_id: { type: string, description: "Issue ID (issue_id or issue_key required)" } + issue_key: { type: string, description: "Issue key (issue_id or issue_key required)" } + limit: { type: number, description: "Maximum comments (default 50)" } + required: [] + outputs_schema: { properties: {} } + - id: edit_comment + name: jira-edit-comment + description: "Edit an existing comment on an issue." + inputs_schema: + properties: + issue_id: { type: string, description: "Issue ID (issue_id or issue_key required)" } + issue_key: { type: string, description: "Issue key (issue_id or issue_key required)" } + comment_id: { type: string, description: "Comment ID" } + comment: { type: string, description: "New comment body" } + visibility: { type: string, description: "Restrict visibility to a role" } + required: [comment_id, comment] + outputs_schema: { properties: {} } + - id: delete_comment + name: jira-delete-comment + description: "Delete a comment from an issue." + inputs_schema: + properties: + issue_id: { type: string, description: "Issue ID (issue_id or issue_key required)" } + issue_key: { type: string, description: "Issue key (issue_id or issue_key required)" } + comment_id: { type: string, description: "Comment ID" } + required: [comment_id] + outputs_schema: { properties: {} } + + # ── Links ───────────────────────────────────────────────────────────────── + - id: add_link + name: jira-add-link + description: "Add a remote web link (URL) to an issue." + inputs_schema: + properties: + issue_id: { type: string, description: "Issue ID (issue_id or issue_key required)" } + issue_key: { type: string, description: "Issue key (issue_id or issue_key required)" } + url: { type: string, description: "URL to link" } + title: { type: string, description: "Link title" } + summary: { type: string, description: "Link summary" } + relationship: { type: string, description: "Relationship to the issue, e.g. causes" } + required: [url, title] + outputs_schema: { properties: {} } + - id: link_issues + name: jira-link-issues + description: "Create a link between two issues (see jira-get-link-types for link_type names)." + inputs_schema: + properties: + inward_issue: { type: string, description: "Inward issue key or ID" } + outward_issue: { type: string, description: "Outward issue key or ID" } + link_type: { type: string, description: "Link type name, e.g. Blocks, Duplicate" } + comment: { type: string, description: "Optional comment to add to the inward issue" } + required: [inward_issue, outward_issue, link_type] + outputs_schema: { properties: {} } + - id: get_link_types + name: jira-get-link-types + description: "List all issue link types." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } + + # ── Attachments ─────────────────────────────────────────────────────────── + - id: upload_file + name: jira-upload-file + description: "Attach a file to an issue (content provided as base64)." + inputs_schema: + properties: + issue_id: { type: string, description: "Issue ID (issue_id or issue_key required)" } + issue_key: { type: string, description: "Issue key (issue_id or issue_key required)" } + file_name: { type: string, description: "Attachment file name" } + content_base64: { type: string, description: "File content, base64-encoded" } + required: [file_name, content_base64] + outputs_schema: { properties: {} } + - id: get_attachment + name: jira-get-attachment + description: "Download an attachment's content by attachment ID (returned as base64)." + risk: read + inputs_schema: + properties: + attachment_id: { type: string, description: "Attachment ID (from jira-get-issue)" } + required: [attachment_id] + outputs_schema: { properties: {} } + - id: delete_attachment + name: jira-delete-attachment + description: "Delete an attachment by attachment ID." + inputs_schema: + properties: + attachment_id: { type: string, description: "Attachment ID" } + required: [attachment_id] + outputs_schema: { properties: {} } + + # ── Fields / users ──────────────────────────────────────────────────────── + - id: list_fields + name: jira-list-fields + description: "List the system and custom issue fields." + risk: read + inputs_schema: + properties: + limit: { type: number, description: "Maximum fields (default 50)" } + offset: { type: number, description: "Starting index (default 0)" } + required: [] + outputs_schema: { properties: {} } + - id: get_id_by_attribute + name: jira-get-id-by-attribute + description: "Resolve a user's account ID (Cloud) or username (On-Prem) from a display name or email." + risk: read + inputs_schema: + properties: + attribute: { type: string, description: "Display name or email address to search for" } + max_results: { type: number, description: "Maximum candidates to consider (default 50)" } + required: [attribute] + outputs_schema: { properties: {} } + - id: get_user_info + name: jira-get-user-info + description: "Get a user's details (account_id for Cloud; key or username for On-Prem). With no identifier, returns the caller." + risk: read + inputs_schema: + properties: + account_id: { type: string, description: "Account ID (Cloud)" } + key: { type: string, description: "User key (On-Prem)" } + username: { type: string, description: "Username (On-Prem)" } + required: [] + outputs_schema: { properties: {} } + + # ── Agile: boards / sprints / epics ─────────────────────────────────────── + - id: board_list + name: jira-board-list + description: "List agile boards (optionally filter by name, project or type)." + risk: read + inputs_schema: + properties: + board_id: { type: string, description: "Return only this board" } + board_name: { type: string, description: "Filter by board name" } + project_key_id: { type: string, description: "Filter by project key or ID" } + type: { type: string, description: "Board type: scrum, kanban or simple" } + limit: { type: number, description: "Maximum boards (default 50)" } + offset: { type: number, description: "Starting index (default 0)" } + required: [] + outputs_schema: { properties: {} } + - id: board_issue_list + name: jira-board-issue-list + description: "List the issues of a board (flattened)." + risk: read + inputs_schema: + properties: + board_id: { type: string, description: "Board ID" } + jql_query: { type: string, description: "JQL to filter the issues" } + limit: { type: number, description: "Maximum issues (default 50)" } + offset: { type: number, description: "Starting index (default 0)" } + required: [board_id] + outputs_schema: { properties: {} } + - id: board_sprint_list + name: jira-board-sprint-list + description: "List the sprints of a board." + risk: read + inputs_schema: + properties: + board_id: { type: string, description: "Board ID" } + limit: { type: number, description: "Maximum sprints (default 50)" } + offset: { type: number, description: "Starting index (default 0)" } + required: [board_id] + outputs_schema: { properties: {} } + - id: sprint_issue_list + name: jira-sprint-issue-list + description: "List the issues of a sprint (flattened)." + risk: read + inputs_schema: + properties: + sprint_id: { type: string, description: "Sprint ID" } + jql_query: { type: string, description: "JQL to filter the issues" } + limit: { type: number, description: "Maximum issues (default 50)" } + offset: { type: number, description: "Starting index (default 0)" } + required: [sprint_id] + outputs_schema: { properties: {} } + - id: epic_issue_list + name: jira-epic-issue-list + description: "List the issues that belong to an epic (flattened)." + risk: read + inputs_schema: + properties: + epic_id: { type: string, description: "Epic ID (epic_id or epic_key required)" } + epic_key: { type: string, description: "Epic key (epic_id or epic_key required)" } + jql_query: { type: string, description: "JQL to filter the issues" } + limit: { type: number, description: "Maximum issues (default 50)" } + offset: { type: number, description: "Starting index (default 0)" } + required: [] + outputs_schema: { properties: {} } + + - id: test_connection + name: jira-test-connection + description: "Verify connectivity and credentials by reading the current user (used by the Test button)." + risk: read + inputs_schema: + properties: {} + required: [] + outputs_schema: { properties: {} } + +ingestion: + command: get_incidents + mapper: get_incidents + default_incident_type: "Jira Issue" diff --git a/integrations/jira/mappers/get_incidents.yaml b/integrations/jira/mappers/get_incidents.yaml new file mode 100644 index 0000000..c5c245a --- /dev/null +++ b/integrations/jira/mappers/get_incidents.yaml @@ -0,0 +1,23 @@ +name: "Jira Issues → OCSF" +description: "Maps a Jira issue (get_incidents, results_path = result) to OCSF finding fields. Issues are flattened by the fetch script (nested fields.* and ADF/rendered descriptions are resolved to plain values)." +field_mappings: + title: "summary" + severity: "priority = 'Highest' ? 5 : (priority = 'High' ? 4 : (priority = 'Medium' ? 3 : 2))" + description: "description" +ocsf: + # ── Finding ─────────────────────────────────────────────────────── + - { source_path: "id", ocsf_field: "finding_info.uid" } + - { source_path: "key", ocsf_field: "finding_info.uid_alt" } + - { source_path: "summary", ocsf_field: "finding_info.title" } + - { source_path: "description", ocsf_field: "finding_info.desc" } + - { source_path: "created", ocsf_field: "finding_info.created_time" } + - { source_path: "updated", ocsf_field: "finding_info.modified_time" } + - { source_path: "url", ocsf_field: "finding_info.src_url" } + - { source_path: "issue_type", ocsf_field: "finding_info.types" } + - { source_path: "project", ocsf_field: "finding_info.analytic.category" } + - { source_path: "status", ocsf_field: "status" } + - { source_path: "resolution", ocsf_field: "disposition" } + # ── People ──────────────────────────────────────────────────────── + - { source_path: "reporter", ocsf_field: "actor.user.name" } + - { source_path: "assignee", ocsf_field: "assignee.name" } + - { source_path: "creator", ocsf_field: "user.name" } diff --git a/integrations/jira/scripts/add_comment.py b/integrations/jira/scripts/add_comment.py new file mode 100644 index 0000000..047a601 --- /dev/null +++ b/integrations/jira/scripts/add_comment.py @@ -0,0 +1,88 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ref = issue_ref(inputs) + comment = str(inputs.get("comment") or "") + if not comment: + raise Exception("comment is required") + visibility = str(inputs.get("visibility") or "") + + body = {"body": adf(comment)} + if visibility: + body["visibility"] = {"type": "role", "value": visibility} + + res = request("POST", "/rest/api/" + _api() + "/issue/" + ref + "/comment", + params={"expand": "renderedBody"}, body=body) + print(json.dumps(res)) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/add_link.py b/integrations/jira/scripts/add_link.py new file mode 100644 index 0000000..9532e3f --- /dev/null +++ b/integrations/jira/scripts/add_link.py @@ -0,0 +1,94 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ref = issue_ref(inputs) + url = str(inputs.get("url") or "") + if not url: + raise Exception("url is required") + title = str(inputs.get("title") or "") + if not title: + raise Exception("title is required") + summary = str(inputs.get("summary") or "") + relationship = str(inputs.get("relationship") or "") + + obj = {"url": url, "title": title} + if summary: + obj["summary"] = summary + body = {"object": obj} + if relationship: + body["relationship"] = relationship + + res = request("POST", "/rest/api/" + _api() + "/issue/" + ref + "/remotelink", body=body) + print(json.dumps(res)) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/assign_issue.py b/integrations/jira/scripts/assign_issue.py new file mode 100644 index 0000000..aebb7a8 --- /dev/null +++ b/integrations/jira/scripts/assign_issue.py @@ -0,0 +1,91 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ref = issue_ref(inputs) + raw_ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + + assignee_id = str(inputs.get("assignee_id") or "").strip() + assignee = str(inputs.get("assignee") or "").strip() + if assignee_id: + body = {"accountId": assignee_id} + who = assignee_id + elif assignee: + body = {"name": assignee} + who = assignee + else: + raise Exception("assignee_id (Cloud) or assignee (On-Prem) is required") + + request("PUT", "/rest/api/" + _api() + "/issue/" + ref + "/assignee", body=body) + print(json.dumps({"ok": True, "issue": raw_ref, "assignee": who})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/board_issue_list.py b/integrations/jira/scripts/board_issue_list.py new file mode 100644 index 0000000..5ec4476 --- /dev/null +++ b/integrations/jira/scripts/board_issue_list.py @@ -0,0 +1,119 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import re + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def flatten_issue(issue): + f = issue.get("fields") or {} + rendered = issue.get("renderedFields") or {} + + def user(u): + u = u or {} + return u.get("displayName") or u.get("name") or u.get("emailAddress") or "" + + desc = rendered.get("description") or "" + if desc: + desc = re.sub(r"<[^>]+>", "", desc) + else: + d = f.get("description") + desc = d if isinstance(d, str) else "" + return { + "id": issue.get("id") or "", + "key": issue.get("key") or "", + "summary": f.get("summary") or "", + "description": desc, + "status": (f.get("status") or {}).get("name") or "", + "resolution": (f.get("resolution") or {}).get("name") or "", + "priority": (f.get("priority") or {}).get("name") or "", + "project": (f.get("project") or {}).get("name") or "", + "issue_type": (f.get("issuetype") or {}).get("name") or "", + "created": f.get("created") or "", + "updated": f.get("updated") or "", + "due_date": f.get("duedate") or "", + "labels": f.get("labels") or [], + "assignee": user(f.get("assignee")), + "reporter": user(f.get("reporter")), + "creator": user(f.get("creator")), + "attachments": [{"id": a.get("id"), "filename": a.get("filename"), "size": a.get("size")} + for a in (f.get("attachment") or [])], + "url": issue.get("self") or "", + } + + +def main(): + args = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + board_id = str(args.get("board_id") or "").strip() + if not board_id: + raise Exception("board_id is required") + jql_query = str(args.get("jql_query") or "").strip() + limit = int(args.get("limit") or 50) + offset = int(args.get("offset") or 0) + + res = request("GET", + "/rest/agile/1.0/board/" + urllib.parse.quote(board_id, safe="") + "/issue", + params={ + "jql": jql_query, + "maxResults": limit, + "startAt": offset, + "expand": "renderedFields", + }) + + print(json.dumps({ + "board_id": board_id, + "result": [flatten_issue(i) for i in res.get("issues", []) or []], + "total": res.get("total", 0), + })) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/board_list.py b/integrations/jira/scripts/board_list.py new file mode 100644 index 0000000..b7c4026 --- /dev/null +++ b/integrations/jira/scripts/board_list.py @@ -0,0 +1,92 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def main(): + args = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + board_id = str(args.get("board_id") or "").strip() + board_name = str(args.get("board_name") or "").strip() + project_key_id = str(args.get("project_key_id") or "").strip() + board_type = str(args.get("type") or "").strip() + limit = int(args.get("limit") or 50) + offset = int(args.get("offset") or 0) + + if board_id: + res = request("GET", "/rest/agile/1.0/board/" + urllib.parse.quote(board_id, safe="")) + boards = [res] + else: + res = request("GET", "/rest/agile/1.0/board", params={ + "name": board_name, + "projectKeyOrId": project_key_id, + "type": board_type, + "maxResults": limit, + "startAt": offset, + }) + boards = res.get("values", []) or [] + + out = [] + for b in boards: + loc = b.get("location") or {} + out.append({ + "id": b.get("id") or "", + "name": b.get("name") or "", + "type": b.get("type") or "", + "project_id": loc.get("projectId", ""), + "project_name": loc.get("projectName", ""), + }) + + print(json.dumps({"boards": out})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/board_sprint_list.py b/integrations/jira/scripts/board_sprint_list.py new file mode 100644 index 0000000..7c3900c --- /dev/null +++ b/integrations/jira/scripts/board_sprint_list.py @@ -0,0 +1,82 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def main(): + args = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + board_id = str(args.get("board_id") or "").strip() + if not board_id: + raise Exception("board_id is required") + limit = int(args.get("limit") or 50) + offset = int(args.get("offset") or 0) + + res = request("GET", + "/rest/agile/1.0/board/" + urllib.parse.quote(board_id, safe="") + "/sprint", + params={"maxResults": limit, "startAt": offset}) + + sprints = [] + for s in res.get("values", []) or []: + sprints.append({ + "id": s.get("id") or "", + "name": s.get("name") or "", + "state": s.get("state") or "", + "start_date": s.get("startDate", ""), + "end_date": s.get("endDate", ""), + "goal": s.get("goal", ""), + }) + + print(json.dumps({"board_id": board_id, "sprints": sprints})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/create_issue.py b/integrations/jira/scripts/create_issue.py new file mode 100644 index 0000000..4ff9cf4 --- /dev/null +++ b/integrations/jira/scripts/create_issue.py @@ -0,0 +1,151 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def resolve_project_by_name(name): + if _is_cloud(): + res = request("GET", "/rest/api/3/project/search", params={"query": name}) + candidates = res.get("values") or [] + else: + res = request("GET", "/rest/api/2/project") + candidates = [p for p in (res or []) if str(p.get("name") or "").lower() == name.lower()] + if len(candidates) == 0: + raise Exception("no project found with name " + name) + if len(candidates) > 1: + raise Exception("multiple projects match name " + name) + return candidates[0] + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + fields = {} + + summary = str(inputs.get("summary") or "").strip() + if not summary: + raise Exception("summary is required") + fields["summary"] = summary + + project_key = str(inputs.get("project_key") or "").strip() + project_name = str(inputs.get("project_name") or "").strip() + if project_key: + fields["project"] = {"key": project_key} + elif project_name: + proj = resolve_project_by_name(project_name) + fields["project"] = {"key": proj.get("key")} + else: + raise Exception("project_key or project_name is required") + + issue_type_id = str(inputs.get("issue_type_id") or "").strip() + issue_type_name = str(inputs.get("issue_type_name") or "").strip() + if issue_type_id: + fields["issuetype"] = {"id": issue_type_id} + elif issue_type_name: + fields["issuetype"] = {"name": issue_type_name} + else: + raise Exception("issue_type_name or issue_type_id is required") + + description = str(inputs.get("description") or "").strip() + if description: + fields["description"] = adf(description) + + priority = str(inputs.get("priority") or "").strip() + if priority: + fields["priority"] = {"name": priority} + + labels = str(inputs.get("labels") or "").strip() + if labels: + fields["labels"] = [x.strip() for x in labels.split(",") if x.strip()] + + components = str(inputs.get("components") or "").strip() + if components: + fields["components"] = [{"name": c.strip()} for c in components.split(",") if c.strip()] + + due_date = str(inputs.get("due_date") or "").strip() + if due_date: + fields["duedate"] = due_date + + assignee_id = str(inputs.get("assignee_id") or "").strip() + assignee = str(inputs.get("assignee") or "").strip() + if assignee_id: + fields["assignee"] = {"accountId": assignee_id} + elif assignee: + fields["assignee"] = {"name": assignee} + + fields_json = str(inputs.get("fields_json") or "").strip() + if fields_json: + extra = json.loads(fields_json) + if not isinstance(extra, dict): + raise Exception("fields_json must be a JSON object") + fields.update(extra) + + res = request("POST", "/rest/api/" + _api() + "/issue", body={"fields": fields}) + print(json.dumps(res)) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/delete_attachment.py b/integrations/jira/scripts/delete_attachment.py new file mode 100644 index 0000000..058dbef --- /dev/null +++ b/integrations/jira/scripts/delete_attachment.py @@ -0,0 +1,84 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + attachment_id = str(inputs.get("attachment_id") or "") + if not attachment_id: + raise Exception("attachment_id is required") + + path = "/rest/api/" + _api() + "/attachment/" + urllib.parse.quote(attachment_id, safe="") + res = request("DELETE", path) + if not res: + res = {"ok": True, "attachment_id": attachment_id} + print(json.dumps(res)) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/delete_comment.py b/integrations/jira/scripts/delete_comment.py new file mode 100644 index 0000000..c58eb45 --- /dev/null +++ b/integrations/jira/scripts/delete_comment.py @@ -0,0 +1,86 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ref = issue_ref(inputs) + comment_id = str(inputs.get("comment_id") or "") + if not comment_id: + raise Exception("comment_id is required") + + path = ("/rest/api/" + _api() + "/issue/" + ref + "/comment/" + + urllib.parse.quote(comment_id, safe="")) + res = request("DELETE", path) + if not res: + res = {"ok": True, "comment_id": comment_id} + print(json.dumps(res)) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/delete_issue.py b/integrations/jira/scripts/delete_issue.py new file mode 100644 index 0000000..c35da81 --- /dev/null +++ b/integrations/jira/scripts/delete_issue.py @@ -0,0 +1,80 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ref = issue_ref(inputs) + raw_ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + + request("DELETE", "/rest/api/" + _api() + "/issue/" + ref, params={"deleteSubtasks": "true"}) + print(json.dumps({"ok": True, "issue": raw_ref})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/edit_comment.py b/integrations/jira/scripts/edit_comment.py new file mode 100644 index 0000000..3368a69 --- /dev/null +++ b/integrations/jira/scripts/edit_comment.py @@ -0,0 +1,92 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ref = issue_ref(inputs) + comment_id = str(inputs.get("comment_id") or "") + if not comment_id: + raise Exception("comment_id is required") + comment = str(inputs.get("comment") or "") + if not comment: + raise Exception("comment is required") + visibility = str(inputs.get("visibility") or "") + + body = {"body": adf(comment)} + if visibility: + body["visibility"] = {"type": "role", "value": visibility} + + path = ("/rest/api/" + _api() + "/issue/" + ref + "/comment/" + + urllib.parse.quote(comment_id, safe="")) + res = request("PUT", path, params={"expand": "renderedBody"}, body=body) + print(json.dumps(res)) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/edit_issue.py b/integrations/jira/scripts/edit_issue.py new file mode 100644 index 0000000..47502f6 --- /dev/null +++ b/integrations/jira/scripts/edit_issue.py @@ -0,0 +1,159 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def build_fields(inputs): + fields = {} + + summary = str(inputs.get("summary") or "").strip() + if summary: + fields["summary"] = summary + + description = str(inputs.get("description") or "").strip() + if description: + fields["description"] = adf(description) + + priority = str(inputs.get("priority") or "").strip() + if priority: + fields["priority"] = {"name": priority} + + labels = str(inputs.get("labels") or "").strip() + if labels: + fields["labels"] = [x.strip() for x in labels.split(",") if x.strip()] + + components = str(inputs.get("components") or "").strip() + if components: + fields["components"] = [{"name": c.strip()} for c in components.split(",") if c.strip()] + + due_date = str(inputs.get("due_date") or "").strip() + if due_date: + fields["duedate"] = due_date + + assignee_id = str(inputs.get("assignee_id") or "").strip() + assignee = str(inputs.get("assignee") or "").strip() + if assignee_id: + fields["assignee"] = {"accountId": assignee_id} + elif assignee: + fields["assignee"] = {"name": assignee} + + fields_json = str(inputs.get("fields_json") or "").strip() + if fields_json: + extra = json.loads(fields_json) + if not isinstance(extra, dict): + raise Exception("fields_json must be a JSON object") + fields.update(extra) + + return fields + + +def find_transition(ref, status, transition): + res = request("GET", "/rest/api/" + _api() + "/issue/" + ref + "/transitions") + transitions = res.get("transitions") or [] + if transition: + wanted = transition.lower() + for t in transitions: + if str(t.get("name") or "").lower() == wanted: + return t.get("id") + names = [str(t.get("name") or "") for t in transitions] + raise Exception("transition not found: " + transition + " — valid transitions: " + ", ".join(names)) + wanted = status.lower() + for t in transitions: + if str((t.get("to") or {}).get("name") or "").lower() == wanted: + return t.get("id") + names = [str((t.get("to") or {}).get("name") or "") for t in transitions] + raise Exception("status not reachable: " + status + " — valid statuses: " + ", ".join(names)) + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ref = issue_ref(inputs) + + status = str(inputs.get("status") or "").strip() + transition = str(inputs.get("transition") or "").strip() + if status and transition: + raise Exception("provide only one of status or transition") + + fields = build_fields(inputs) + + if status or transition: + tid = find_transition(ref, status, transition) + body = {"transition": {"id": str(tid)}} + if fields: + body["fields"] = fields + request("POST", "/rest/api/" + _api() + "/issue/" + ref + "/transitions", body=body) + elif fields: + request("PUT", "/rest/api/" + _api() + "/issue/" + ref, body={"fields": fields}) + else: + raise Exception("nothing to update") + + res = request("GET", "/rest/api/" + _api() + "/issue/" + ref) + print(json.dumps(res)) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/epic_issue_list.py b/integrations/jira/scripts/epic_issue_list.py new file mode 100644 index 0000000..5a1fdb0 --- /dev/null +++ b/integrations/jira/scripts/epic_issue_list.py @@ -0,0 +1,121 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import re + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def flatten_issue(issue): + f = issue.get("fields") or {} + rendered = issue.get("renderedFields") or {} + + def user(u): + u = u or {} + return u.get("displayName") or u.get("name") or u.get("emailAddress") or "" + + desc = rendered.get("description") or "" + if desc: + desc = re.sub(r"<[^>]+>", "", desc) + else: + d = f.get("description") + desc = d if isinstance(d, str) else "" + return { + "id": issue.get("id") or "", + "key": issue.get("key") or "", + "summary": f.get("summary") or "", + "description": desc, + "status": (f.get("status") or {}).get("name") or "", + "resolution": (f.get("resolution") or {}).get("name") or "", + "priority": (f.get("priority") or {}).get("name") or "", + "project": (f.get("project") or {}).get("name") or "", + "issue_type": (f.get("issuetype") or {}).get("name") or "", + "created": f.get("created") or "", + "updated": f.get("updated") or "", + "due_date": f.get("duedate") or "", + "labels": f.get("labels") or [], + "assignee": user(f.get("assignee")), + "reporter": user(f.get("reporter")), + "creator": user(f.get("creator")), + "attachments": [{"id": a.get("id"), "filename": a.get("filename"), "size": a.get("size")} + for a in (f.get("attachment") or [])], + "url": issue.get("self") or "", + } + + +def main(): + args = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + epic_id = str(args.get("epic_id") or "").strip() + epic_key = str(args.get("epic_key") or "").strip() + ref = epic_id or epic_key + if not ref: + raise Exception("epic_id or epic_key is required") + jql_query = str(args.get("jql_query") or "").strip() + limit = int(args.get("limit") or 50) + offset = int(args.get("offset") or 0) + + res = request("GET", + "/rest/agile/1.0/epic/" + urllib.parse.quote(ref, safe="") + "/issue", + params={ + "jql": jql_query, + "maxResults": limit, + "startAt": offset, + "expand": "renderedFields", + }) + + print(json.dumps({ + "epic": ref, + "result": [flatten_issue(i) for i in res.get("issues", []) or []], + "total": res.get("total", 0), + })) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/get_attachment.py b/integrations/jira/scripts/get_attachment.py new file mode 100644 index 0000000..36b31d8 --- /dev/null +++ b/integrations/jira/scripts/get_attachment.py @@ -0,0 +1,98 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + attachment_id = str(inputs.get("attachment_id") or "") + if not attachment_id: + raise Exception("attachment_id is required") + quoted = urllib.parse.quote(attachment_id, safe="") + + meta = request("GET", "/rest/api/" + _api() + "/attachment/" + quoted) + + if _is_cloud(): + data = request("GET", "/rest/api/3/attachment/content/" + quoted, raw=True) + else: + # On-Prem serves the bytes from the metadata "content" URL (urllib follows the redirect). + content_url = meta.get("content", "") + if not content_url: + raise Exception("attachment content URL is missing") + data = request("GET", "", full_url=content_url, raw=True) + + print(json.dumps({ + "id": meta.get("id", attachment_id), + "filename": meta.get("filename", ""), + "mime_type": meta.get("mimeType", ""), + "size": meta.get("size", 0), + "content_base64": base64.b64encode(data).decode("ascii"), + })) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/get_comments.py b/integrations/jira/scripts/get_comments.py new file mode 100644 index 0000000..841363c --- /dev/null +++ b/integrations/jira/scripts/get_comments.py @@ -0,0 +1,105 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import re + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def _body_text(comment): + rendered = comment.get("renderedBody") + if rendered: + return re.sub(r"<[^>]+>", "", str(rendered)) + raw_body = comment.get("body") + if isinstance(raw_body, str): + return raw_body + return "" + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + raw_ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + ref = issue_ref(inputs) + limit = inputs.get("limit") or 50 + + res = request("GET", "/rest/api/" + _api() + "/issue/" + ref + "/comment", + params={"maxResults": limit, "expand": "renderedBody"}) + + comments = [] + for c in res.get("comments", []) or []: + comments.append({ + "id": c.get("id", ""), + "body": _body_text(c), + "author": c.get("author", {}).get("displayName", ""), + "created": c.get("created", ""), + "updated": c.get("updated", ""), + "update_author": c.get("updateAuthor", {}).get("displayName", ""), + }) + + print(json.dumps({"issue": raw_ref, "comments": comments})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/get_id_by_attribute.py b/integrations/jira/scripts/get_id_by_attribute.py new file mode 100644 index 0000000..1cf99be --- /dev/null +++ b/integrations/jira/scripts/get_id_by_attribute.py @@ -0,0 +1,104 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def main(): + args = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + attribute = str(args.get("attribute") or "").strip() + if not attribute: + raise Exception("attribute is required") + max_results = int(args.get("max_results") or 50) + + cloud = _is_cloud() + if cloud: + res = request("GET", "/rest/api/3/user/search", + params={"query": attribute, "maxResults": max_results}) + else: + res = request("GET", "/rest/api/2/user/search", + params={"username": attribute, "maxResults": max_results}) + + users = res if isinstance(res, list) else (res.get("values") or []) + + if len(users) == 1: + u = users[0] + account_id = u.get("accountId") or u.get("name") or "" + print(json.dumps({"attribute": attribute, "account_id": account_id})) + return + + needle = attribute.lower() + candidates = [] + for u in users: + values = [str(u.get("displayName") or ""), str(u.get("emailAddress") or "")] + if not cloud: + values.append(str(u.get("name") or "")) + if any(v.lower() == needle for v in values if v): + candidates.append(u.get("accountId") or "" if cloud else u.get("name") or "") + + if len(candidates) == 1: + print(json.dumps({"attribute": attribute, "account_id": candidates[0]})) + elif len(candidates) == 0: + print(json.dumps({ + "attribute": attribute, + "account_id": "", + "message": "no unambiguous match — try the exact display name or email", + })) + else: + print(json.dumps({ + "attribute": attribute, + "account_id": "", + "message": "multiple matches — provide a more specific attribute", + })) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/get_incidents.py b/integrations/jira/scripts/get_incidents.py new file mode 100644 index 0000000..10c1a11 --- /dev/null +++ b/integrations/jira/scripts/get_incidents.py @@ -0,0 +1,156 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import re +from datetime import datetime, timezone + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def flatten_issue(issue): + f = issue.get("fields") or {} + rendered = issue.get("renderedFields") or {} + + def user(u): + u = u or {} + return u.get("displayName") or u.get("name") or u.get("emailAddress") or "" + + desc = rendered.get("description") or "" + if desc: + desc = re.sub(r"<[^>]+>", "", desc) + else: + d = f.get("description") + desc = d if isinstance(d, str) else "" + return { + "id": issue.get("id") or "", + "key": issue.get("key") or "", + "summary": f.get("summary") or "", + "description": desc, + "status": (f.get("status") or {}).get("name") or "", + "resolution": (f.get("resolution") or {}).get("name") or "", + "priority": (f.get("priority") or {}).get("name") or "", + "project": (f.get("project") or {}).get("name") or "", + "issue_type": (f.get("issuetype") or {}).get("name") or "", + "created": f.get("created") or "", + "updated": f.get("updated") or "", + "due_date": f.get("duedate") or "", + "labels": f.get("labels") or [], + "assignee": user(f.get("assignee")), + "reporter": user(f.get("reporter")), + "creator": user(f.get("creator")), + "attachments": [{"id": a.get("id"), "filename": a.get("filename"), "size": a.get("size")} + for a in (f.get("attachment") or [])], + "url": issue.get("self") or "", + } + + +def search_issues(jql, max_results, next_page_token=None, start_at=None): + params = {"jql": jql, "maxResults": max_results, "fields": "*all", "expand": "renderedFields,names"} + if _is_cloud() and not start_at: + if next_page_token: + params["nextPageToken"] = next_page_token + return request("GET", "/rest/api/3/search/jql", params=params) + if start_at: + params["startAt"] = start_at + return request("GET", "/rest/api/" + _api() + "/search", params=params) + + +def to_jira_time(v): + s = str(v or "").strip() + if not s: + return None + if s.isdigit(): + ts = int(s) + if ts > 10 ** 12: + ts = ts // 1000 + return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M") + try: + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc) + return dt.strftime("%Y-%m-%d %H:%M") + except Exception: + return s + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + query = str(inputs.get("query") or "").strip() + max_results = int(inputs.get("max") or 50) + + watermark = to_jira_time(inputs.get("after")) + + jql = query + if watermark: + clause = 'created >= "' + watermark + '"' + jql = (jql + " AND " + clause) if jql else clause + jql = jql + " ORDER BY created ASC" + + res = search_issues(jql, max_results) + print(json.dumps({"result": [flatten_issue(i) for i in res.get("issues", [])]})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/get_issue.py b/integrations/jira/scripts/get_issue.py new file mode 100644 index 0000000..5dbfc5e --- /dev/null +++ b/integrations/jira/scripts/get_issue.py @@ -0,0 +1,117 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import re + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def flatten_issue(issue): + f = issue.get("fields") or {} + rendered = issue.get("renderedFields") or {} + + def user(u): + u = u or {} + return u.get("displayName") or u.get("name") or u.get("emailAddress") or "" + + desc = rendered.get("description") or "" + if desc: + desc = re.sub(r"<[^>]+>", "", desc) + else: + d = f.get("description") + desc = d if isinstance(d, str) else "" + return { + "id": issue.get("id") or "", + "key": issue.get("key") or "", + "summary": f.get("summary") or "", + "description": desc, + "status": (f.get("status") or {}).get("name") or "", + "resolution": (f.get("resolution") or {}).get("name") or "", + "priority": (f.get("priority") or {}).get("name") or "", + "project": (f.get("project") or {}).get("name") or "", + "issue_type": (f.get("issuetype") or {}).get("name") or "", + "created": f.get("created") or "", + "updated": f.get("updated") or "", + "due_date": f.get("duedate") or "", + "labels": f.get("labels") or [], + "assignee": user(f.get("assignee")), + "reporter": user(f.get("reporter")), + "creator": user(f.get("creator")), + "attachments": [{"id": a.get("id"), "filename": a.get("filename"), "size": a.get("size")} + for a in (f.get("attachment") or [])], + "url": issue.get("self") or "", + } + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ref = issue_ref(inputs) + res = request("GET", "/rest/api/" + _api() + "/issue/" + ref, + params={"expand": "renderedFields,names,transitions"}) + print(json.dumps(flatten_issue(res))) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/get_link_types.py b/integrations/jira/scripts/get_link_types.py new file mode 100644 index 0000000..1b3c0ac --- /dev/null +++ b/integrations/jira/scripts/get_link_types.py @@ -0,0 +1,84 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + res = request("GET", "/rest/api/" + _api() + "/issueLinkType") + link_types = [] + for t in res.get("issueLinkTypes", []) or []: + link_types.append({ + "id": t.get("id", ""), + "name": t.get("name", ""), + "inward": t.get("inward", ""), + "outward": t.get("outward", ""), + }) + print(json.dumps({"link_types": link_types})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/get_user_info.py b/integrations/jira/scripts/get_user_info.py new file mode 100644 index 0000000..d640a32 --- /dev/null +++ b/integrations/jira/scripts/get_user_info.py @@ -0,0 +1,88 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def main(): + args = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + account_id = str(args.get("account_id") or "").strip() + key = str(args.get("key") or "").strip() + username = str(args.get("username") or "").strip() + + params = {} + if _is_cloud(): + if account_id: + params["accountId"] = account_id + else: + if key: + params["key"] = key + elif username: + params["username"] = username + + if params: + res = request("GET", "/rest/api/" + _api() + "/user", params=params) + else: + res = request("GET", "/rest/api/" + _api() + "/myself") + + print(json.dumps({ + "account_id": res.get("accountId", ""), + "key": res.get("key", ""), + "name": res.get("name", ""), + "display_name": res.get("displayName", ""), + "email": res.get("emailAddress", ""), + "active": res.get("active"), + "timezone": res.get("timeZone", ""), + })) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/link_issues.py b/integrations/jira/scripts/link_issues.py new file mode 100644 index 0000000..8d70cff --- /dev/null +++ b/integrations/jira/scripts/link_issues.py @@ -0,0 +1,110 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def _issue_selector(value): + # A numeric value is an issue id; anything else is an issue key. + if str(value).isdigit(): + return {"id": str(value)} + return {"key": str(value)} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + inward_issue = str(inputs.get("inward_issue") or "") + if not inward_issue: + raise Exception("inward_issue is required") + outward_issue = str(inputs.get("outward_issue") or "") + if not outward_issue: + raise Exception("outward_issue is required") + link_type = str(inputs.get("link_type") or "") + if not link_type: + raise Exception("link_type is required") + comment = str(inputs.get("comment") or "") + + body = { + "type": {"name": link_type}, + "inwardIssue": _issue_selector(inward_issue), + "outwardIssue": _issue_selector(outward_issue), + } + if comment: + body["comment"] = {"body": adf(comment)} + + res = request("POST", "/rest/api/" + _api() + "/issueLink", body=body) + if not res: + res = { + "ok": True, + "inward_issue": inward_issue, + "outward_issue": outward_issue, + "link_type": link_type, + } + print(json.dumps(res)) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/list_fields.py b/integrations/jira/scripts/list_fields.py new file mode 100644 index 0000000..8c2b199 --- /dev/null +++ b/integrations/jira/scripts/list_fields.py @@ -0,0 +1,79 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def main(): + args = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + limit = int(args.get("limit") or 50) + offset = int(args.get("offset") or 0) + + res = request("GET", "/rest/api/" + _api() + "/field") + rows = res if isinstance(res, list) else [] + sliced = rows[offset:offset + limit] + + fields = [] + for f in sliced: + fields.append({ + "id": f.get("id") or "", + "key": f.get("key") or "", + "name": f.get("name") or "", + "custom": f.get("custom"), + "searchable": f.get("searchable"), + "type": (f.get("schema") or {}).get("type", ""), + }) + + print(json.dumps({"total": len(rows), "fields": fields})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/list_transitions.py b/integrations/jira/scripts/list_transitions.py new file mode 100644 index 0000000..7586680 --- /dev/null +++ b/integrations/jira/scripts/list_transitions.py @@ -0,0 +1,84 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ref = issue_ref(inputs) + raw_ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + + res = request("GET", "/rest/api/" + _api() + "/issue/" + ref + "/transitions") + transitions = [ + {"id": t.get("id"), "name": t.get("name"), "to": (t.get("to") or {}).get("name", "")} + for t in (res.get("transitions") or []) + ] + print(json.dumps({"issue": raw_ref, "transitions": transitions})) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/query_issues.py b/integrations/jira/scripts/query_issues.py new file mode 100644 index 0000000..bb74bb3 --- /dev/null +++ b/integrations/jira/scripts/query_issues.py @@ -0,0 +1,138 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import re + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def flatten_issue(issue): + f = issue.get("fields") or {} + rendered = issue.get("renderedFields") or {} + + def user(u): + u = u or {} + return u.get("displayName") or u.get("name") or u.get("emailAddress") or "" + + desc = rendered.get("description") or "" + if desc: + desc = re.sub(r"<[^>]+>", "", desc) + else: + d = f.get("description") + desc = d if isinstance(d, str) else "" + return { + "id": issue.get("id") or "", + "key": issue.get("key") or "", + "summary": f.get("summary") or "", + "description": desc, + "status": (f.get("status") or {}).get("name") or "", + "resolution": (f.get("resolution") or {}).get("name") or "", + "priority": (f.get("priority") or {}).get("name") or "", + "project": (f.get("project") or {}).get("name") or "", + "issue_type": (f.get("issuetype") or {}).get("name") or "", + "created": f.get("created") or "", + "updated": f.get("updated") or "", + "due_date": f.get("duedate") or "", + "labels": f.get("labels") or [], + "assignee": user(f.get("assignee")), + "reporter": user(f.get("reporter")), + "creator": user(f.get("creator")), + "attachments": [{"id": a.get("id"), "filename": a.get("filename"), "size": a.get("size")} + for a in (f.get("attachment") or [])], + "url": issue.get("self") or "", + } + + +def search_issues(jql, max_results, next_page_token=None, start_at=None): + params = {"jql": jql, "maxResults": max_results, "fields": "*all", "expand": "renderedFields,names"} + if _is_cloud() and not start_at: + if next_page_token: + params["nextPageToken"] = next_page_token + return request("GET", "/rest/api/3/search/jql", params=params) + if start_at: + params["startAt"] = start_at + return request("GET", "/rest/api/" + _api() + "/search", params=params) + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + query = str(inputs.get("query") or "").strip() + if not query: + raise Exception("query is required") + max_results = int(inputs.get("max_results") or 50) + next_page_token = str(inputs.get("next_page_token") or "") or None + start_at = inputs.get("start_at") + start_at = int(start_at) if str(start_at or "").strip() != "" else None + + res = search_issues(query, max_results, next_page_token=next_page_token, start_at=start_at) + print(json.dumps({ + "result": [flatten_issue(i) for i in res.get("issues", [])], + "next_page_token": res.get("nextPageToken", ""), + "total": res.get("total", 0), + })) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/sprint_issue_list.py b/integrations/jira/scripts/sprint_issue_list.py new file mode 100644 index 0000000..10f793c --- /dev/null +++ b/integrations/jira/scripts/sprint_issue_list.py @@ -0,0 +1,119 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import re + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def flatten_issue(issue): + f = issue.get("fields") or {} + rendered = issue.get("renderedFields") or {} + + def user(u): + u = u or {} + return u.get("displayName") or u.get("name") or u.get("emailAddress") or "" + + desc = rendered.get("description") or "" + if desc: + desc = re.sub(r"<[^>]+>", "", desc) + else: + d = f.get("description") + desc = d if isinstance(d, str) else "" + return { + "id": issue.get("id") or "", + "key": issue.get("key") or "", + "summary": f.get("summary") or "", + "description": desc, + "status": (f.get("status") or {}).get("name") or "", + "resolution": (f.get("resolution") or {}).get("name") or "", + "priority": (f.get("priority") or {}).get("name") or "", + "project": (f.get("project") or {}).get("name") or "", + "issue_type": (f.get("issuetype") or {}).get("name") or "", + "created": f.get("created") or "", + "updated": f.get("updated") or "", + "due_date": f.get("duedate") or "", + "labels": f.get("labels") or [], + "assignee": user(f.get("assignee")), + "reporter": user(f.get("reporter")), + "creator": user(f.get("creator")), + "attachments": [{"id": a.get("id"), "filename": a.get("filename"), "size": a.get("size")} + for a in (f.get("attachment") or [])], + "url": issue.get("self") or "", + } + + +def main(): + args = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + sprint_id = str(args.get("sprint_id") or "").strip() + if not sprint_id: + raise Exception("sprint_id is required") + jql_query = str(args.get("jql_query") or "").strip() + limit = int(args.get("limit") or 50) + offset = int(args.get("offset") or 0) + + res = request("GET", + "/rest/agile/1.0/sprint/" + urllib.parse.quote(sprint_id, safe="") + "/issue", + params={ + "jql": jql_query, + "maxResults": limit, + "startAt": offset, + "expand": "renderedFields", + }) + + print(json.dumps({ + "sprint_id": sprint_id, + "result": [flatten_issue(i) for i in res.get("issues", []) or []], + "total": res.get("total", 0), + })) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/test_connection.py b/integrations/jira/scripts/test_connection.py new file mode 100644 index 0000000..9f455ba --- /dev/null +++ b/integrations/jira/scripts/test_connection.py @@ -0,0 +1,82 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + res = request("GET", "/rest/api/" + _api() + "/myself") + if not res.get("accountId") and not res.get("name"): + raise Exception("Unexpected response from Jira: no user identity returned") + print(json.dumps({ + "ok": True, + "user": res.get("displayName") or res.get("name") or "", + "cloud": _is_cloud(), + })) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) diff --git a/integrations/jira/scripts/upload_file.py b/integrations/jira/scripts/upload_file.py new file mode 100644 index 0000000..c0f6987 --- /dev/null +++ b/integrations/jira/scripts/upload_file.py @@ -0,0 +1,111 @@ +import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import mimetypes +import uuid + + +def _cfg(): + return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) + + +def _is_cloud(): + return bool(str(_cfg().get("cloud_id") or "").strip()) + + +def _api(): + return "3" if _is_cloud() else "2" + + +def _base(): + cfg = _cfg() + root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/") + cloud_id = str(cfg.get("cloud_id") or "").strip() + return root + "/" + cloud_id if cloud_id else root + + +def _auth(): + cfg = _cfg() + pat = str(cfg.get("pat") or "") + if pat: + return "Bearer " + pat + cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + + +def request(method, path, params=None, body=None, full_url=None, raw=False): + url = full_url or (_base() + path) + q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")} + if q: + url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q) + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Accept": "application/json", "Authorization": _auth()} + if data is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req, timeout=90) as r: + content = r.read() + if raw: + return content + return json.loads(content) if content else {} + + +def issue_ref(inputs): + ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "") + if not ref: + raise Exception("issue_id or issue_key is required") + return urllib.parse.quote(ref, safe="") + + +def adf(text): + # Jira Cloud (API v3) expects Atlassian Document Format for rich text; On-Prem (v2) takes plain text. + if not _is_cloud(): + return text + return {"type": "doc", "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} + + +def main(): + inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) + ref = issue_ref(inputs) + file_name = str(inputs.get("file_name") or "") + if not file_name: + raise Exception("file_name is required") + content_base64 = str(inputs.get("content_base64") or "") + if not content_base64: + raise Exception("content_base64 is required") + + boundary = "----riposte" + uuid.uuid4().hex + content = base64.b64decode(content_base64) + mime = mimetypes.guess_type(file_name)[0] or "application/octet-stream" + + body = ( + ("--" + boundary + "\r\n" + + 'Content-Disposition: form-data; name="file"; filename="' + file_name + '"\r\n' + + "Content-Type: " + mime + "\r\n\r\n").encode("utf-8") + + content + + ("\r\n--" + boundary + "--\r\n").encode("utf-8") + ) + + headers = { + "Accept": "application/json", + "Authorization": _auth(), + # Jira requires this header for attachment uploads (XSRF check bypass). + "X-Atlassian-Token": "no-check", + "Content-Type": "multipart/form-data; boundary=" + boundary, + } + + url = _base() + "/rest/api/" + _api() + "/issue/" + ref + "/attachments" + req = urllib.request.Request(url, data=body, headers=headers, method="POST") + with urllib.request.urlopen(req, timeout=120) as r: + raw = r.read() + res = json.loads(raw) if raw else {} + print(json.dumps(res)) + + +try: + main() +except urllib.error.HTTPError as e: + print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1)