2354ab0a00
A personal access token silently outranked Basic credentials. Anything left in
pat — an email pasted into the wrong field, a token from a decommissioned Data
Center — became the Authorization header, and Atlassian answered "Client must
be authenticated to access this resource." That sentence names no field, so the
search starts at the URL, the API version, the token itself: everywhere except
the one field that caused it.
Basic authentication now wins whenever username and api_key are both set, and
pat serves the rest, so a token left behind cannot outrank credentials someone
has just filled in. A lone username, or a lone api_key, is not a pair and does
not claim the request. A 401 or 403 says which method was used and whether the
other was configured and ignored.
Three configurations that cannot work are refused with the reason rather than
sent to Jira:
- no usable credential, where an empty username/api_key pair was being
base64-encoded into a valid-looking header;
- the Cloud gateway URL with an empty cloud_id, which addresses no site, so
every path under it is a dead link and Jira replies with an HTML 404 that
explains nothing;
- a cloud_id set against any other host, where it is not an address at all,
only a path segment that does not exist.
Credentials are stripped before use: one pasted with a trailing newline
corrupts the header and reads back as a wrong password.
Error bodies are condensed instead of dumped. Jira's 404 page is four hundred
lines of AUI markup, stylesheet links and inline scripts around one sentence,
and it was going into the test modal whole; what surfaces now is the <title>
and the readable text, capped. A 2xx that is not JSON — a login page, an SSO
redirect, a proxy standing in for the API — reports what answered and the URL
the redirects landed on, in place of a JSON parser offset.
Config descriptions now say which fields pair with which deployment, and which
credential is used when both are present.
Applied to all 27 scripts; the seven shared helper blocks stay identical across
them.
169 lines
6.8 KiB
Python
169 lines
6.8 KiB
Python
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", "{}"))
|
|
ref = issue_ref(inputs)
|
|
raw_ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "")
|
|
|
|
res = request("GET", "/rest/api/" + _api() + "/issue/" + ref + "/transitions")
|
|
transitions = [
|
|
{"id": t.get("id"), "name": t.get("name"), "to": (t.get("to") or {}).get("name", "")}
|
|
for t in (res.get("transitions") or [])
|
|
]
|
|
print(json.dumps({"issue": raw_ref, "transitions": transitions}))
|
|
|
|
|
|
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)
|