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>
This commit is contained in:
@@ -142,24 +142,46 @@ def adf(text):
|
||||
"content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]}
|
||||
|
||||
|
||||
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 _body_text(comment):
|
||||
rendered = comment.get("renderedBody")
|
||||
if rendered:
|
||||
return re.sub(r"<[^>]+>", "", str(rendered))
|
||||
return _html_text(rendered)
|
||||
raw_body = comment.get("body")
|
||||
if isinstance(raw_body, str):
|
||||
return raw_body
|
||||
return ""
|
||||
|
||||
|
||||
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 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
|
||||
order = str(inputs.get("order") or "oldest").strip().lower()
|
||||
if order not in ("oldest", "newest"):
|
||||
raise Exception("order is oldest or newest, not " + order)
|
||||
start_at = inputs.get("start_at")
|
||||
start_at = int(start_at) if str(start_at or "").strip() != "" else 0
|
||||
|
||||
res = request("GET", "/rest/api/" + _api() + "/issue/" + ref + "/comment",
|
||||
params={"maxResults": limit, "expand": "renderedBody"})
|
||||
params={"maxResults": limit, "startAt": start_at,
|
||||
"orderBy": "-created" if order == "newest" else "created",
|
||||
"expand": "renderedBody"})
|
||||
|
||||
comments = []
|
||||
for c in res.get("comments", []) or []:
|
||||
@@ -167,13 +189,15 @@ def main():
|
||||
"id": c.get("id", ""),
|
||||
"body": _body_text(c),
|
||||
"author": c.get("author", {}).get("displayName", ""),
|
||||
"author_id": _user_id(c.get("author")),
|
||||
"created": c.get("created", ""),
|
||||
"updated": c.get("updated", ""),
|
||||
"update_author": c.get("updateAuthor", {}).get("displayName", ""),
|
||||
})
|
||||
|
||||
print(json.dumps({"issue": raw_ref, "comments": comments}))
|
||||
|
||||
total = int(res.get("total") or 0)
|
||||
print(json.dumps({"issue": raw_ref, "comments": comments, "total": total,
|
||||
"next_start_at": start_at + len(comments) if start_at + len(comments) < total else None}))
|
||||
|
||||
try:
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user