import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error import re from datetime import datetime, timedelta, 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() 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) ) # A key goes into the JQL as is, so it must look like one: PROJECT-123. Anything # else would let a field value written by a playbook rewrite the query. KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*-[0-9]+$") MAX_KEYS = 50 COMMENT_PAGE = 50 def parse_keys(raw): if isinstance(raw, str): raw = re.split(r"[\s,;]+", raw) keys, invalid = [], [] for k in raw or []: k = str(k or "").strip() if not k: continue if KEY_RE.match(k): k = k.upper() if k not in keys: keys.append(k) else: invalid.append(k) return keys, invalid def parse_time(v): # Accepts what Riposte sends (ISO8601, epoch s or ms) and what Jira returns # (2026-10-13T14:32:05.123+0200 — an offset without a colon, which # fromisoformat only reads from Python 3.11 on). 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) s = s.replace("Z", "+00:00") m = re.match(r"^(.*[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)([+-]\d{2})(\d{2})$", s) if m: s = m.group(1) + m.group(2) + ":" + m.group(3) try: dt = datetime.fromisoformat(s) except ValueError: raise Exception("since is not a date: " + str(v) + " (expected ISO8601 or an epoch)") if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt def jql_time(since, tz_name): # JQL reads a date in the time zone of the account running the query, and # only to the minute. Written in UTC it would be off by the account's # offset, and an update in that gap would never come back. So the instant # is converted to the account's zone and moved a minute back; the exact # cut is made below, on the timestamps Jira returns. Without a usable zone # the window is widened by a day instead of guessed. margin = timedelta(minutes=1) try: from zoneinfo import ZoneInfo local = since.astimezone(ZoneInfo(tz_name)) if tz_name else None except Exception: local = None if local is None: local = since.astimezone(timezone.utc) margin = timedelta(days=1) return (local - margin).strftime("%Y/%m/%d %H:%M") def html_text(rendered): t = re.sub(r"(?i)", "\n", str(rendered)) t = re.sub(r"(?i)", "\n", t) t = re.sub(r"<[^>]+>", "", t) t = html.unescape(t) t = re.sub(r"[ \t\r\f\v]+\n", "\n", t) return re.sub(r"\n{3,}", "\n\n", t).strip() def adf_text(node): # A Cloud body without its rendered form is Atlassian Document Format. if isinstance(node, str): return node if not isinstance(node, dict): return "" if node.get("type") == "text": return node.get("text") or "" if node.get("type") == "hardBreak": return "\n" inner = "".join(adf_text(c) for c in node.get("content") or []) if node.get("type") in ("paragraph", "heading", "listItem", "codeBlock", "blockquote"): inner += "\n" return inner def body_text(comment): rendered = comment.get("renderedBody") if rendered: return html_text(rendered) raw = comment.get("body") if isinstance(raw, str): return raw.strip() return adf_text(raw).strip() def user_id(u): # Cloud identifies a user by accountId; On-Prem by key, then name. u = u or {} return str(u.get("accountId") or u.get("key") or u.get("name") or "") def user_name(u): u = u or {} return u.get("displayName") or u.get("name") or u.get("emailAddress") or "" def browse_base(): # The Cloud gateway is not where a person opens a ticket: the site URL is. if _is_cloud(): try: info = request("GET", "/rest/api/3/serverInfo") return str(info.get("baseUrl") or "").rstrip("/") except Exception: return "" return str(_cfg().get("server_url") or "").rstrip("/") def search(keys, since_clause): jql = "key in (" + ",".join(keys) + ")" if since_clause: jql += ' AND updated >= "' + since_clause + '"' params = {"jql": jql, "maxResults": MAX_KEYS, "fields": "status,updated", "expand": "changelog"} if _is_cloud(): return request("GET", "/rest/api/3/search/jql", params=params).get("issues") or [] return request("GET", "/rest/api/2/search", params=params).get("issues") or [] def fetch_one(key): try: return request("GET", "/rest/api/" + _api() + "/issue/" + urllib.parse.quote(key, safe=""), params={"fields": "status,updated", "expand": "changelog"}) except urllib.error.HTTPError as e: if e.code == 404: return None raise def find_issues(keys, since_clause): # JQL refuses the whole query when one key does not exist any more (a # ticket deleted or moved to another project). The keys are then read one # by one, so a single dead ticket does not blind the watch on the others. try: return search(keys, since_clause), [] except urllib.error.HTTPError as e: if e.code != 400: raise found, missing = [], [] for k in keys: issue = fetch_one(k) if issue is None: missing.append(k) continue found.append(issue) return found, missing def new_comments(key, since, me): # Newest first, page by page, until the comments are older than since. A # first look (no since) returns the newest page only: the caller records # them as seen and announces nothing. out, start = [], 0 while True: res = request("GET", "/rest/api/" + _api() + "/issue/" + urllib.parse.quote(key, safe="") + "/comment", params={"orderBy": "-created", "startAt": start, "maxResults": COMMENT_PAGE, "expand": "renderedBody"}) page = res.get("comments") or [] page.sort(key=lambda c: parse_time(c.get("created")) or datetime.min.replace(tzinfo=timezone.utc), reverse=True) older = False for c in page: created = parse_time(c.get("created")) if since is not None and created is not None and created < since: older = True break author = c.get("author") or {} aid = user_id(author) out.append({ "id": str(c.get("id") or ""), "author_id": aid, "author": user_name(author), "created": c.get("created") or "", "updated": c.get("updated") or "", "body": body_text(c), "self": bool(aid) and aid == me, }) total = int(res.get("total") or 0) start += len(page) if since is None or older or not page or start >= total: break out.reverse() return out def status_changes(issue, since): out = [] for h in ((issue.get("changelog") or {}).get("histories") or []): at = parse_time(h.get("created")) if since is not None and at is not None and at < since: continue for item in h.get("items") or []: if item.get("field") != "status": continue out.append({ "id": str(h.get("id") or ""), "at": h.get("created") or "", "author_id": user_id(h.get("author")), "author": user_name(h.get("author")), "from": item.get("fromString") or "", "to": item.get("toString") or "", }) out.sort(key=lambda s: parse_time(s["at"]) or datetime.min.replace(tzinfo=timezone.utc)) return out def main(): inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) keys, invalid = parse_keys(inputs.get("keys")) if len(keys) > MAX_KEYS: raise Exception(str(len(keys)) + " keys given, at most " + str(MAX_KEYS) + " per call: split them") since = parse_time(inputs.get("since")) me = request("GET", "/rest/api/" + _api() + "/myself") me_id = user_id(me) result = { "self": {"id": me_id, "name": user_name(me)}, "since": since.isoformat() if since else "", "issues": [], "missing": [], "invalid_keys": invalid, } if not keys: print(json.dumps(result)) return clause = jql_time(since, me.get("timeZone")) if since else "" issues, missing = find_issues(keys, clause) result["missing"] = missing base = browse_base() returned = set() for issue in issues: key = issue.get("key") or "" returned.add(key.upper()) f = issue.get("fields") or {} updated = parse_time(f.get("updated")) if since is not None and updated is not None and updated < since: continue result["issues"].append({ "key": key, "id": str(issue.get("id") or ""), "updated": f.get("updated") or "", "status": (f.get("status") or {}).get("name") or "", "url": (base + "/browse/" + key) if base else "", "comments": new_comments(key, since, me_id), "status_changes": status_changes(issue, since), }) if not since: # On a first look every key should come back; one that does not is # gone or out of the account's reach. result["missing"] = sorted(set(result["missing"]) | {k for k in keys if k not in returned}) print(json.dumps(result)) 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 += " — 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)