5ef589f4bdca750ae44fabb5c648378e8264b2c9
max
  Tue Sep 15 04:56:51 2026 -0700
redmineCli: match the 403/404 prefix, not anywhere in the error text

summarize_issue treats a ticket as unreadable when api_get reports 403 or 404, and
re-raises everything else so a real failure is not hidden behind "(not readable)". The
test looked for "HTTP 403 GET" anywhere in the message, but that message carries up to
500 characters of the server's response body, so a 500 or a proxy error page quoting
those words in its own text was swallowed as well. Anchor it to the prefix the tool
builds.

diff --git src/utils/redmineCli src/utils/redmineCli
index ca4f4f4396b..a7623b44b01 100755
--- src/utils/redmineCli
+++ src/utils/redmineCli
@@ -1191,36 +1191,46 @@
     """Return a list of (other_ticket_id, relation_type) for a ticket's relations.
     The relation JSON names both ends (issue_id, issue_to_id); the "other" end is
     whichever one is not this ticket."""
     data = api_get(base_url, f"/issues/{ticket_id}.json?include=relations", api_key)
     tid = int(ticket_id)
     rels = []
     for r in data["issue"].get("relations", []):
         other = r["issue_to_id"] if r["issue_id"] == tid else r["issue_id"]
         rels.append((other, r.get("relation_type", "relates")))
     # Stable order: by relation type, then ticket id.
     rels.sort(key=lambda t: (t[1], t[0]))
     return rels
 
 
 def summarize_issue(base_url, ticket_id, api_key):
-    """Return {id, subject, status, tracker} for a ticket, or None if it can't be read
-    (e.g. deleted or no permission)."""
+    """Return {id, subject, status, tracker} for a ticket, or None if the ticket itself
+    is not accessible (deleted -> 404, or no permission -> 403).  Any other failure
+    (network trouble, rate limiting, bad API key) is re-raised, so it is not silently
+    hidden behind a "(not readable)" label."""
     try:
         d = api_get(base_url, f"/issues/{ticket_id}.json", api_key)["issue"]
-    except SystemExit:
+    except SystemExit as e:
+        # api_get exits with "Error: HTTP <code> GET <url>: <body>", where body is up to 500
+        # characters of whatever the server sent.  Match the prefix we build, not anywhere in
+        # the string: a 502 or a proxy error page that happens to quote "HTTP 404 GET" in its
+        # own text would otherwise be swallowed and labelled "(not readable)", which is exactly
+        # the confusion this function exists to prevent.
+        msg = str(e)
+        if msg.startswith("Error: HTTP 403 GET ") or msg.startswith("Error: HTTP 404 GET "):
             return None
+        raise
     return {"id": d["id"], "subject": d.get("subject", ""),
             "status": d.get("status", {}).get("name", ""),
             "tracker": d.get("tracker", {}).get("name", "")}
 
 
 def related_lines(base_url, ticket_id, api_key):
     """Build markdown bullet lines describing a ticket's related tickets, one per line
     with tracker/status and subject.  Returns [] if there are none."""
     rels = fetch_relations(base_url, ticket_id, api_key)
     if not rels:
         return []
     lines = []
     for other_id, rtype in rels:
         info = summarize_issue(base_url, other_id, api_key)
         if info: