diff --git a/integrations/jira/manifest.yaml b/integrations/jira/manifest.yaml index 2554402..a9bf1ee 100644 --- a/integrations/jira/manifest.yaml +++ b/integrations/jira/manifest.yaml @@ -1,8 +1,8 @@ id: jira name: Jira -version: 1.0.0 +version: 1.0.1 description: "Atlassian Jira (REST API, Cloud and On-Prem/Data Center) — issue lifecycle (create/read/edit/delete, transitions, assign), comments, web + issue links, attachments (upload/download/delete), field and user lookups, agile boards/sprints/epics, plus issue ingestion (get_incidents) with an OCSF mapper. Basic (email + API token) or Personal Access Token authentication. Stdlib-only, no extra Python dependencies." -changelog: "1.0.0 — Initial release: JQL ingestion + query, full issue CRUD and transitions, comments, links, attachments, fields/users, and agile board/sprint/epic listing. Cloud (API v3, ADF bodies) and On-Prem (API v2) supported." +changelog: "1.0.1 — A personal access token silently outranked Basic credentials, so an unusable value left in pat turned every call into an opaque 401. Basic authentication now wins whenever username and api_key are both set, pat serves the rest, and a 401/403 names the method it used and whether the other was configured and ignored. Three configurations that cannot work are refused with the reason instead of being sent to Jira: no usable credential, the Cloud gateway URL without a cloud_id, and a cloud_id set against any other host. HTML error pages are condensed to their message rather than dumped whole, and a non-JSON body reports what answered and where. 1.0.0 — Initial release: JQL ingestion + query, full issue CRUD and transitions, comments, links, attachments, fields/users, and agile board/sprint/epic listing. Cloud (API v3, ADF bodies) and On-Prem (API v2) supported." category: ticketing # Per-instance configuration. For Jira Cloud set cloud_id (found at @@ -14,21 +14,21 @@ config_schema: properties: server_url: type: string - description: "Base URL. Jira Cloud: https://api.atlassian.com/ex/jira (default). On-Prem: your Jira server URL." + description: "Base URL. Jira Cloud: keep the default and set cloud_id. On-Prem/Data Center: your own Jira URL, e.g. https://jira.example.com, with cloud_id left empty." default: https://api.atlassian.com/ex/jira cloud_id: type: string - description: "Jira Cloud ID (leave empty for On-Prem/Data Center)" + description: "Jira Cloud ID, found at admin.atlassian.com/s//users. Required alongside the Cloud gateway URL; leave empty for On-Prem/Data Center." username: type: string - description: "Account email (Basic auth)" + description: "Account email (Basic auth, Jira Cloud). Pairs with api_key: both must be set for Basic authentication to be used." api_key: type: string - description: "API token (Basic auth)" + description: "API token (Basic auth, Jira Cloud). Created at id.atlassian.com/manage-profile/security/api-tokens. Pairs with username." x-soar-sensitive: true pat: type: string - description: "Personal Access Token (use instead of username/api_key)" + description: "Personal Access Token (On-Prem/Data Center only). Used when username and api_key are not both set. Leave empty on Cloud." x-soar-sensitive: true required: - server_url diff --git a/integrations/jira/scripts/add_comment.py b/integrations/jira/scripts/add_comment.py index 047a601..7cbef08 100644 --- a/integrations/jira/scripts/add_comment.py +++ b/integrations/jira/scripts/add_comment.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -81,7 +161,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/add_link.py b/integrations/jira/scripts/add_link.py index 9532e3f..5e977df 100644 --- a/integrations/jira/scripts/add_link.py +++ b/integrations/jira/scripts/add_link.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -87,7 +167,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/assign_issue.py b/integrations/jira/scripts/assign_issue.py index aebb7a8..2188436 100644 --- a/integrations/jira/scripts/assign_issue.py +++ b/integrations/jira/scripts/assign_issue.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -84,7 +164,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/board_issue_list.py b/integrations/jira/scripts/board_issue_list.py index 5ec4476..e4f9808 100644 --- a/integrations/jira/scripts/board_issue_list.py +++ b/integrations/jira/scripts/board_issue_list.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error import re @@ -18,16 +18,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -44,7 +110,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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 flatten_issue(issue): @@ -112,7 +192,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/board_list.py b/integrations/jira/scripts/board_list.py index b7c4026..2a4f07b 100644 --- a/integrations/jira/scripts/board_list.py +++ b/integrations/jira/scripts/board_list.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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 main(): @@ -85,7 +165,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/board_sprint_list.py b/integrations/jira/scripts/board_sprint_list.py index 7c3900c..9f9a119 100644 --- a/integrations/jira/scripts/board_sprint_list.py +++ b/integrations/jira/scripts/board_sprint_list.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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 main(): @@ -75,7 +155,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/create_issue.py b/integrations/jira/scripts/create_issue.py index 4ff9cf4..1bdd63b 100644 --- a/integrations/jira/scripts/create_issue.py +++ b/integrations/jira/scripts/create_issue.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -144,7 +224,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/delete_attachment.py b/integrations/jira/scripts/delete_attachment.py index 058dbef..444ccd7 100644 --- a/integrations/jira/scripts/delete_attachment.py +++ b/integrations/jira/scripts/delete_attachment.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -77,7 +157,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/delete_comment.py b/integrations/jira/scripts/delete_comment.py index c58eb45..d193267 100644 --- a/integrations/jira/scripts/delete_comment.py +++ b/integrations/jira/scripts/delete_comment.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -79,7 +159,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/delete_issue.py b/integrations/jira/scripts/delete_issue.py index c35da81..01e4899 100644 --- a/integrations/jira/scripts/delete_issue.py +++ b/integrations/jira/scripts/delete_issue.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -73,7 +153,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/edit_comment.py b/integrations/jira/scripts/edit_comment.py index 3368a69..2cb0801 100644 --- a/integrations/jira/scripts/edit_comment.py +++ b/integrations/jira/scripts/edit_comment.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -85,7 +165,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/edit_issue.py b/integrations/jira/scripts/edit_issue.py index 47502f6..21768b4 100644 --- a/integrations/jira/scripts/edit_issue.py +++ b/integrations/jira/scripts/edit_issue.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -152,7 +232,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/epic_issue_list.py b/integrations/jira/scripts/epic_issue_list.py index 5a1fdb0..f4da488 100644 --- a/integrations/jira/scripts/epic_issue_list.py +++ b/integrations/jira/scripts/epic_issue_list.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error import re @@ -18,16 +18,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -44,7 +110,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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 flatten_issue(issue): @@ -114,7 +194,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/get_attachment.py b/integrations/jira/scripts/get_attachment.py index 36b31d8..d519b98 100644 --- a/integrations/jira/scripts/get_attachment.py +++ b/integrations/jira/scripts/get_attachment.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -91,7 +171,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/get_comments.py b/integrations/jira/scripts/get_comments.py index 841363c..23ca327 100644 --- a/integrations/jira/scripts/get_comments.py +++ b/integrations/jira/scripts/get_comments.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error import re @@ -18,16 +18,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -44,7 +110,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -98,7 +178,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/get_id_by_attribute.py b/integrations/jira/scripts/get_id_by_attribute.py index 1cf99be..ebe3254 100644 --- a/integrations/jira/scripts/get_id_by_attribute.py +++ b/integrations/jira/scripts/get_id_by_attribute.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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 main(): @@ -97,7 +177,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/get_incidents.py b/integrations/jira/scripts/get_incidents.py index 10c1a11..d2db0f7 100644 --- a/integrations/jira/scripts/get_incidents.py +++ b/integrations/jira/scripts/get_incidents.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error import re from datetime import datetime, timezone @@ -19,16 +19,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -45,7 +111,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -149,7 +229,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/get_issue.py b/integrations/jira/scripts/get_issue.py index 5dbfc5e..35f56f9 100644 --- a/integrations/jira/scripts/get_issue.py +++ b/integrations/jira/scripts/get_issue.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error import re @@ -18,16 +18,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -44,7 +110,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -110,7 +190,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/get_link_types.py b/integrations/jira/scripts/get_link_types.py index 1b3c0ac..4908d89 100644 --- a/integrations/jira/scripts/get_link_types.py +++ b/integrations/jira/scripts/get_link_types.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -77,7 +157,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/get_user_info.py b/integrations/jira/scripts/get_user_info.py index d640a32..f0db4ab 100644 --- a/integrations/jira/scripts/get_user_info.py +++ b/integrations/jira/scripts/get_user_info.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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 main(): @@ -81,7 +161,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/link_issues.py b/integrations/jira/scripts/link_issues.py index 8d70cff..82b217d 100644 --- a/integrations/jira/scripts/link_issues.py +++ b/integrations/jira/scripts/link_issues.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -103,7 +183,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/list_fields.py b/integrations/jira/scripts/list_fields.py index 8c2b199..e142f29 100644 --- a/integrations/jira/scripts/list_fields.py +++ b/integrations/jira/scripts/list_fields.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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 main(): @@ -72,7 +152,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/list_transitions.py b/integrations/jira/scripts/list_transitions.py index 7586680..832e743 100644 --- a/integrations/jira/scripts/list_transitions.py +++ b/integrations/jira/scripts/list_transitions.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -77,7 +157,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/query_issues.py b/integrations/jira/scripts/query_issues.py index bb74bb3..0c7776e 100644 --- a/integrations/jira/scripts/query_issues.py +++ b/integrations/jira/scripts/query_issues.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error import re @@ -18,16 +18,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -44,7 +110,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -131,7 +211,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/sprint_issue_list.py b/integrations/jira/scripts/sprint_issue_list.py index 10f793c..21a6fc6 100644 --- a/integrations/jira/scripts/sprint_issue_list.py +++ b/integrations/jira/scripts/sprint_issue_list.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error import re @@ -18,16 +18,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -44,7 +110,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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 flatten_issue(issue): @@ -112,7 +192,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/test_connection.py b/integrations/jira/scripts/test_connection.py index 9f455ba..9ceef02 100644 --- a/integrations/jira/scripts/test_connection.py +++ b/integrations/jira/scripts/test_connection.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error def _cfg(): @@ -17,16 +17,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -43,7 +109,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -75,7 +155,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)})) diff --git a/integrations/jira/scripts/upload_file.py b/integrations/jira/scripts/upload_file.py index c0f6987..8bb3daf 100644 --- a/integrations/jira/scripts/upload_file.py +++ b/integrations/jira/scripts/upload_file.py @@ -1,4 +1,4 @@ -import base64, json, os, sys, urllib.parse, urllib.request, urllib.error +import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error import mimetypes import uuid @@ -19,16 +19,82 @@ 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 _auth(): +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() - pat = str(cfg.get("pat") or "") - if pat: + 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 - cred = (str(cfg.get("username") or "") + ":" + str(cfg.get("api_key") or "")).encode("utf-8") - return "Basic " + base64.b64encode(cred).decode("ascii") + 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): @@ -45,7 +111,21 @@ def request(method, path, params=None, body=None, full_url=None, raw=False): content = r.read() if raw: return content - return json.loads(content) if content else {} + 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): @@ -104,7 +184,11 @@ def main(): try: main() except urllib.error.HTTPError as e: - print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) + 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)}))