Files
f3nris 725e6921f4 feat(jira): watch_issues reports what changed on a set of tickets (1.4.0)
Given up to 50 keys and an instant, returns the issues updated since with
their status, new comments (author id, plain-text body, own comments
flagged) and status transitions, plus the instance's own account. JQL dates
are converted to the account's time zone; malformed keys are never queried;
a deleted key falls back to per-key reads instead of failing the batch.

get_comments gains order, start_at and author_id, and decodes HTML entities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:59:42 +00:00

413 lines
15 KiB
Python

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/<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)
)
# 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)<br\s*/?>", "\n", str(rendered))
t = re.sub(r"(?i)</(p|div|li|h[1-6]|tr|blockquote|pre)>", "\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)