feat(jira): list the saved filters, so one can be referenced by id

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.
This commit is contained in:
2026-09-02 17:32:35 +02:00
parent b9822f310d
commit ad1fa1559b
2 changed files with 214 additions and 4 deletions
+199
View File
@@ -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/<cloud_id>/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 <title>
# 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[^>]*>(.*?)</title>", text, re.S | re.I)
body = re.sub(r"<(script|style|head)[^>]*>.*?</\1>", " ", 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)