From ad1fa1559b72938ab0fe0a567c0e73ee2517ab97 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Wed, 2 Sep 2026 17:32:35 +0200 Subject: [PATCH] feat(jira): list the saved filters, so one can be referenced by id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A saved Jira filter could always be ingested: "filter = 12345" is ordinary JQL, and the fetch's own created >= watermark clause sits on top of it happily. What was missing was the id. Nothing in the product named the filters an instance holds, so the only way to reach one was to open Jira, find the filter, and read the number out of the browser's address bar — which is enough friction that the workflow may as well not exist, and the alternative is retyping a JQL query the SOC already maintains in Jira. list_filters returns each filter's id, name, JQL and owner, plus jql_reference: the clause to paste, already written. Cloud reads filter/search, which pages and can match a name server-side; Data Center reads filter/favourite, present across versions where filter/search is not, and pages and matches locally. Both response shapes — a paginated envelope, a bare array — were checked against what the API actually returns. get_incidents and query_issues now say a filter can go in their query box, and which command hands over the id. --- integrations/jira/manifest.yaml | 19 ++- integrations/jira/scripts/list_filters.py | 199 ++++++++++++++++++++++ 2 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 integrations/jira/scripts/list_filters.py diff --git a/integrations/jira/manifest.yaml b/integrations/jira/manifest.yaml index bf21c3c..0c4fdf1 100644 --- a/integrations/jira/manifest.yaml +++ b/integrations/jira/manifest.yaml @@ -1,8 +1,8 @@ id: jira name: Jira -version: 1.0.2 +version: 1.1.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.2 — Command field help rewritten: every input now says what to type, in what format, and which command finds the value, instead of restating the field name. get_incidents explains that the fetch adds its own created >= watermark clause and ORDER BY, that the watermark is filled by the alert rule, and that a run does not page. Assignee, board, sprint, comment and attachment fields name the command that returns their id. 1.0.1 — A personal access token silently outranked Basic credentials, so an unusable value left in pat turned every call into an opaque 401. Basic authentication now wins whenever username and api_key are both set, pat serves the rest, and a 401/403 names the method it used and whether the other was configured and ignored. Three configurations that cannot work are refused with the reason instead of being sent to Jira: no usable credential, the Cloud gateway URL without a cloud_id, and a cloud_id set against any other host. HTML error pages are condensed to their message rather than dumped whole, and a non-JSON body reports what answered and where. 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." +changelog: "1.1.0 — New command list_filters: the saved filters a Jira instance already holds, with their id, JQL and owner, plus the exact clause to paste into a fetch. A saved filter could always be ingested — filter = 12345 is ordinary JQL — but nothing told you the id, so the workflow was invisible. get_incidents and query_issues now mention it in their help. 1.0.2 — Command field help rewritten: every input now says what to type, in what format, and which command finds the value, instead of restating the field name. get_incidents explains that the fetch adds its own created >= watermark clause and ORDER BY, that the watermark is filled by the alert rule, and that a run does not page. Assignee, board, sprint, comment and attachment fields name the command that returns their id. 1.0.1 — A personal access token silently outranked Basic credentials, so an unusable value left in pat turned every call into an opaque 401. Basic authentication now wins whenever username and api_key are both set, pat serves the rest, and a 401/403 names the method it used and whether the other was configured and ignored. Three configurations that cannot work are refused with the reason instead of being sent to Jira: no usable credential, the Cloud gateway URL without a cloud_id, and a cloud_id set against any other host. HTML error pages are condensed to their message rather than dumped whole, and a non-JSON body reports what answered and where. 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 @@ -41,7 +41,7 @@ commands: risk: read inputs_schema: properties: - query: { type: string, description: "JQL selecting the issues to ingest, e.g. project = SOC AND status != Done. Empty means every issue the account can read. The fetch appends its own created >= watermark clause and ORDER BY created ASC — do not write either yourself." } + query: { type: string, description: "JQL selecting the issues to ingest, e.g. project = SOC AND status != Done — or filter = 12345 to reuse a saved Jira filter, whose id list_filters gives you. Empty means every issue the account can read. The fetch appends its own created >= watermark clause and ORDER BY created ASC — do not write either yourself." } after: { type: string, description: "Only issues created at or after this instant: ISO8601 like 2026-09-01T14:00:00Z, or an epoch in seconds or milliseconds. Leave empty — the alert rule fills it with the previous run's watermark." } max: { type: number, description: "Issues per run, default 50. The fetch makes one call and does not page, so a run that would exceed this silently drops the rest: raise it, or shorten the rule's interval." } required: [] @@ -56,12 +56,23 @@ commands: risk: read inputs_schema: properties: - query: { type: string, description: "JQL, e.g. project = SOC AND created >= -7d ORDER BY created DESC. Unlike the ingestion fetch, the ordering here is yours to set." } + query: { type: string, description: "JQL, e.g. project = SOC AND created >= -7d ORDER BY created DESC, or filter = 12345 to run a saved filter (list_filters gives the id). Unlike the ingestion fetch, the ordering here is yours to set." } max_results: { type: number, description: "Issues per page, default 50." } next_page_token: { type: string, description: "Cloud only. The nextPageToken from the previous response, to get the page after it. Empty for the first page." } start_at: { type: number, description: "On-Prem only. Index of the first issue: 0, then 50, then 100… Cloud pages with next_page_token instead." } required: [query] outputs_schema: { properties: {} } + - id: list_filters + name: jira-list-filters + description: "List the saved Jira filters that can be referenced from a JQL query. Returns each filter's id, name, JQL and owner, plus jql_reference — the clause to paste straight into a fetch or a query." + risk: read + inputs_schema: + properties: + name: { type: string, description: "Keep only filters whose name contains this text, e.g. SOC. Empty lists them all." } + limit: { type: number, description: "Filters per page, default 50." } + offset: { type: number, description: "Index of the first row, for paging: 0, then 50, then 100…" } + required: [] + outputs_schema: { properties: {} } - id: get_issue name: jira-get-issue description: "Retrieve a single issue by ID or key (flattened)." diff --git a/integrations/jira/scripts/list_filters.py b/integrations/jira/scripts/list_filters.py new file mode 100644 index 0000000..1d18759 --- /dev/null +++ b/integrations/jira/scripts/list_filters.py @@ -0,0 +1,199 @@ +import base64, html, json, os, re, 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() + if not cloud_id and "api.atlassian.com" in root: + # The Cloud gateway addresses a site by its cloud id; without one every + # path below it is a dead link, and Jira answers with an HTML 404 that + # says nothing about the cause. + raise Exception( + "server_url is the Jira Cloud gateway (" + root + ") but cloud_id is empty. " + "Set cloud_id, found at admin.atlassian.com/s//users, or point " + "server_url at your own Jira URL for On-Prem/Data Center." + ) + if cloud_id and "api.atlassian.com" not in root: + # The reverse mistake, and the harder one to see: against any other host + # a cloud id is just a path segment, and every call 404s. + raise Exception( + "cloud_id is set but server_url (" + root + ") is not the Jira Cloud gateway. " + "A cloud id addresses a site through https://api.atlassian.com/ex/jira only. " + "Clear cloud_id for On-Prem/Data Center, or restore the gateway URL." + ) + return root + "/" + cloud_id if cloud_id else root + + +def _creds(): + # A credential pasted from a password manager often carries a trailing + # newline; unstripped it corrupts the header and reads back as a wrong + # password. Basic wins whenever the pair is complete: a token left behind + # from another deployment must not outrank credentials just filled in. + # A lone username, or a lone api_key, is not a pair. + cfg = _cfg() + user = str(cfg.get("username") or "").strip() + key = str(cfg.get("api_key") or "").strip() + pat = str(cfg.get("pat") or "").strip() + mode = "basic" if (user and key) else ("pat" if pat else "") + return mode, user, key, pat + + +def _auth_method(): + # Named in a 401/403, where the useful question is which credential was sent. + mode, user, key, pat = _creds() + if mode == "basic": + return "Basic authentication as " + user + ( + " (a personal access token is also configured and was ignored)" if pat else "" + ) + if mode == "pat": + half = "" + if user or key: + half = " (" + ("username" if user else "api_key") + " is set but its pair is " + half += "empty, so Basic authentication was not used)" + return "a personal access token" + half + return "no credential" + + +def _auth(): + mode, user, key, pat = _creds() + if mode == "basic": + cred = (user + ":" + key).encode("utf-8") + return "Basic " + base64.b64encode(cred).decode("ascii") + if mode == "pat": + return "Bearer " + pat + raise Exception( + "No usable credential. Jira Cloud needs username (account email) together with " + "api_key (API token); On-Prem/Data Center needs pat. " + + ("Only " + ("username" if user else "api_key") + " is set." + if (user or key) else "None of them is set.") + ) + + +def _summarize(raw, ctype): + # An error page is a wall of markup around one sentence. Keep the + # and the readable text: the rest only makes a log impossible to read. + text = raw.decode("utf-8", "replace") if isinstance(raw, bytes) else str(raw) + if "json" in (ctype or "").lower(): + return text.strip()[:500] + title = re.search(r"<title[^>]*>(.*?)", text, re.S | re.I) + body = re.sub(r"<(script|style|head)[^>]*>.*?", " ", text, flags=re.S | re.I) + body = re.sub(r"<[^>]+>", " ", body) + out = (title.group(1) + " \u2014 " if title else "") + body + return re.sub(r"\s+", " ", html.unescape(out)).strip()[:500] + + +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 + if not content: + return {} + try: + return json.loads(content) + except ValueError: + # A 2xx that is not JSON is a login page, an SSO redirect or a proxy + # answering in place of Jira. The URL the redirects landed on is + # usually the whole diagnosis. + landed = urllib.parse.urlsplit(r.geturl()) + ctype = r.headers.get("Content-Type") or "unknown" + raise Exception( + "Jira answered " + str(r.getcode()) + " with a non-JSON body at " + + landed.scheme + "://" + landed.netloc + landed.path + + " (content-type: " + ctype + "): " + _summarize(content, ctype) + ) + + +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", "{}")) + name = str(inputs.get("name") or "").strip() + limit = int(inputs.get("limit") or 50) + offset = int(inputs.get("offset") or 0) + + if _is_cloud(): + params = {"expand": "jql", "maxResults": limit, "startAt": offset} + if name: + params["filterName"] = name + res = request("GET", "/rest/api/3/filter/search", params=params) + raw = res.get("values") or [] + total = res.get("total") + else: + # Data Center has no filter/search across every version, but the + # favourites endpoint is there throughout. It returns the operator's own + # starred filters, which is what a saved-filter workflow relies on. + raw = request("GET", "/rest/api/2/filter/favourite", params={"expand": "jql"}) + if not isinstance(raw, list): + raw = [] + if name: + needle = name.lower() + raw = [f for f in raw if needle in str(f.get("name") or "").lower()] + total = len(raw) + raw = raw[offset:offset + limit] + + filters = [] + for f in raw: + owner = f.get("owner") or {} + filters.append({ + "id": str(f.get("id") or ""), + "name": f.get("name") or "", + "jql": f.get("jql") or "", + "owner": owner.get("displayName") or owner.get("name") or "", + "favourite": bool(f.get("favourite")), + "url": f.get("viewUrl") or f.get("self") or "", + # What to paste into a fetch or a query, so the id never has to be + # copied by hand from a Jira URL. + "jql_reference": "filter = " + str(f.get("id") or ""), + }) + + print(json.dumps({"filters": filters, "total": total})) + + +try: + main() +except urllib.error.HTTPError as e: + ctype = e.headers.get("Content-Type", "") if e.headers else "" + detail = _summarize(e.read(), ctype) + if e.code in (401, 403): + detail += " \u2014 the request was sent using " + _auth_method() + print(json.dumps({"error": "HTTP " + str(e.code), "detail": detail})) + sys.exit(1) +except Exception as e: + print(json.dumps({"error": str(e)})) + sys.exit(1)