From 2354ab0a005be0dd5693affffd2d464830175078 Mon Sep 17 00:00:00 2001 From: Guillaume BOURGEOIS Date: Wed, 2 Sep 2026 12:36:33 +0200 Subject: [PATCH] fix(jira): name the credential that failed instead of returning a bare 401 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A personal access token silently outranked Basic credentials. Anything left in pat — an email pasted into the wrong field, a token from a decommissioned Data Center — became the Authorization header, and Atlassian answered "Client must be authenticated to access this resource." That sentence names no field, so the search starts at the URL, the API version, the token itself: everywhere except the one field that caused it. Basic authentication now wins whenever username and api_key are both set, and pat serves the rest, so a token left behind cannot outrank credentials someone has just filled in. A lone username, or a lone api_key, is not a pair and does not claim the request. A 401 or 403 says which method was used and whether the other was configured and ignored. Three configurations that cannot work are refused with the reason rather than sent to Jira: - no usable credential, where an empty username/api_key pair was being base64-encoded into a valid-looking header; - the Cloud gateway URL with an empty cloud_id, which addresses no site, so every path under it is a dead link and Jira replies with an HTML 404 that explains nothing; - a cloud_id set against any other host, where it is not an address at all, only a path segment that does not exist. Credentials are stripped before use: one pasted with a trailing newline corrupts the header and reads back as a wrong password. Error bodies are condensed instead of dumped. Jira's 404 page is four hundred lines of AUI markup, stylesheet links and inline scripts around one sentence, and it was going into the test modal whole; what surfaces now is the and the readable text, capped. A 2xx that is not JSON — a login page, an SSO redirect, a proxy standing in for the API — reports what answered and the URL the redirects landed on, in place of a JSON parser offset. Config descriptions now say which fields pair with which deployment, and which credential is used when both are present. Applied to all 27 scripts; the seven shared helper blocks stay identical across them. --- integrations/jira/manifest.yaml | 14 +-- integrations/jira/scripts/add_comment.py | 100 ++++++++++++++++-- integrations/jira/scripts/add_link.py | 100 ++++++++++++++++-- integrations/jira/scripts/assign_issue.py | 100 ++++++++++++++++-- integrations/jira/scripts/board_issue_list.py | 100 ++++++++++++++++-- integrations/jira/scripts/board_list.py | 100 ++++++++++++++++-- .../jira/scripts/board_sprint_list.py | 100 ++++++++++++++++-- integrations/jira/scripts/create_issue.py | 100 ++++++++++++++++-- .../jira/scripts/delete_attachment.py | 100 ++++++++++++++++-- integrations/jira/scripts/delete_comment.py | 100 ++++++++++++++++-- integrations/jira/scripts/delete_issue.py | 100 ++++++++++++++++-- integrations/jira/scripts/edit_comment.py | 100 ++++++++++++++++-- integrations/jira/scripts/edit_issue.py | 100 ++++++++++++++++-- integrations/jira/scripts/epic_issue_list.py | 100 ++++++++++++++++-- integrations/jira/scripts/get_attachment.py | 100 ++++++++++++++++-- integrations/jira/scripts/get_comments.py | 100 ++++++++++++++++-- .../jira/scripts/get_id_by_attribute.py | 100 ++++++++++++++++-- integrations/jira/scripts/get_incidents.py | 100 ++++++++++++++++-- integrations/jira/scripts/get_issue.py | 100 ++++++++++++++++-- integrations/jira/scripts/get_link_types.py | 100 ++++++++++++++++-- integrations/jira/scripts/get_user_info.py | 100 ++++++++++++++++-- integrations/jira/scripts/link_issues.py | 100 ++++++++++++++++-- integrations/jira/scripts/list_fields.py | 100 ++++++++++++++++-- integrations/jira/scripts/list_transitions.py | 100 ++++++++++++++++-- integrations/jira/scripts/query_issues.py | 100 ++++++++++++++++-- .../jira/scripts/sprint_issue_list.py | 100 ++++++++++++++++-- integrations/jira/scripts/test_connection.py | 100 ++++++++++++++++-- integrations/jira/scripts/upload_file.py | 100 ++++++++++++++++-- 28 files changed, 2491 insertions(+), 223 deletions(-) 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/<cloud_id>/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/<cloud_id>/users, or point " + "server_url at your own Jira URL for On-Prem/Data Center." + ) + if cloud_id and "api.atlassian.com" not in root: + # The reverse mistake, and the harder one to see: against any other host + # a cloud id is just a path segment, and every call 404s. + raise Exception( + "cloud_id is set but server_url (" + root + ") is not the Jira Cloud gateway. " + "A cloud id addresses a site through https://api.atlassian.com/ex/jira only. " + "Clear cloud_id for On-Prem/Data Center, or restore the gateway URL." + ) return root + "/" + cloud_id if cloud_id else root -def _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 <title> + # and the readable text: the rest only makes a log impossible to read. + text = raw.decode("utf-8", "replace") if isinstance(raw, bytes) else str(raw) + if "json" in (ctype or "").lower(): + return text.strip()[:500] + title = re.search(r"<title[^>]*>(.*?)", 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)}))