Compare commits
4 Commits
6f51520fb5
...
725e6921f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 725e6921f4 | |||
| e973ccb673 | |||
| 62157e5dee | |||
| 91855fce4f |
File diff suppressed because one or more lines are too long
@@ -135,14 +135,57 @@ def count(filters):
|
||||
return int(reply.get("total_count") or 0)
|
||||
|
||||
|
||||
def version(v):
|
||||
# "8.5.0.12345" -> (8, 5, 0, 12345); a shorter target such as "8.5" reads as
|
||||
# 8.5.0.0, so it compares against every build of that release line.
|
||||
parts = []
|
||||
for x in str(v or "").strip().split("."):
|
||||
digits = "".join(c for c in x if c.isdigit())
|
||||
parts.append(int(digits) if digits else 0)
|
||||
while len(parts) < 4:
|
||||
parts.append(0)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def count_local(filters, keep):
|
||||
# The endpoints grid filters neither on operational_status nor on the agent
|
||||
# version, so the endpoints the other filters match are read 100 at a time —
|
||||
# the API's page ceiling — and counted here. One call per hundred endpoints.
|
||||
n, start = 0, 0
|
||||
while True:
|
||||
request_data = {"search_from": start, "search_to": start + 100, "filters": filters}
|
||||
reply = post("/endpoints/get_endpoint/", request_data).get("reply") or {}
|
||||
page = reply.get("endpoints") or []
|
||||
n += sum(1 for e in page if keep(e))
|
||||
start += len(page)
|
||||
if not page or start >= int(reply.get("total_count") or 0):
|
||||
return n
|
||||
|
||||
|
||||
def main():
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
filters = build_filters(inputs)
|
||||
result = {"count": count(filters), "filters": filters}
|
||||
result = {"filters": filters}
|
||||
tests = []
|
||||
wanted = {x.upper() for x in csv(inputs.get("operational_status"))}
|
||||
if wanted:
|
||||
tests.append(lambda e: str(e.get("operational_status") or "").upper() in wanted)
|
||||
result["operational_status"] = sorted(wanted)
|
||||
below = str(inputs.get("agent_version_lt") or "").strip()
|
||||
if below:
|
||||
target = version(below)
|
||||
# An endpoint that reports no version is not counted as outdated: it is
|
||||
# unknown, and calling it outdated would inflate the figure.
|
||||
tests.append(lambda e: bool(e.get("agent_version")) and version(e.get("agent_version")) < target)
|
||||
result["agent_version_lt"] = below
|
||||
if tests:
|
||||
result["count"] = count_local(filters, lambda e: all(t(e) for t in tests))
|
||||
else:
|
||||
result["count"] = count(filters)
|
||||
# The fleet total is the denominator the filtered count is read against: it
|
||||
# turns a bare number into a proportion, and makes a filter that matched the
|
||||
# whole estate — because it was too broad — visible instead of plausible.
|
||||
result["total"] = count([]) if filters else result["count"]
|
||||
result["total"] = count([]) if filters or tests else result["count"]
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
id: jira
|
||||
name: Jira
|
||||
version: 1.3.0
|
||||
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.3.0 — create_issue and edit_issue offer the Jira site's own values: the project, the issue type of that project and the priority are picked from a list the playbook editor loads from the instance, as is link_issues' link type. Three read commands feed them — list_projects, list_issue_types and list_priorities — and each field keeps accepting a typed value or an expression. 1.2.0 — Declare the risk tier on the 11 commands that carried none: 8 safe_write, 3 destructive. An unset risk is read as destructive by the agent approval gate, so the 8 that only annotate sat behind the same prompt as the ones that change the estate. | 1.1.0 — New command list_filters: the saved filters a Jira instance already holds, with their id, JQL and owner, plus the exact clause to paste into a fetch. A saved filter could always be ingested — filter = 12345 is ordinary JQL — but nothing told you the id, so the workflow was invisible. get_incidents and query_issues now mention it in their help. 1.0.2 — Command field help rewritten: every input now says what to type, in what format, and which command finds the value, instead of restating the field name. get_incidents explains that the fetch adds its own created >= watermark clause and ORDER BY, that the watermark is filled by the alert rule, and that a run does not page. Assignee, board, sprint, comment and attachment fields name the command that returns their id. 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."
|
||||
version: 1.4.0
|
||||
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), a change watch over a set of tickets (watch_issues) with an OCSF mapper. Basic (email + API token) or Personal Access Token authentication. Stdlib-only, no extra Python dependencies."
|
||||
changelog: "1.4.0 — New command watch_issues: given up to 50 issue keys and an instant, it returns the ones changed since, each with its current status, the comments added since (author id and name, plain-text body) and the status transitions from its changelog, plus the identity of the account the instance runs as, so a caller can tell its own comments from the customer's. It is what lets Riposte follow the ticket a case opened. JQL reads a date in the account's time zone and to the minute: the instant is converted to that zone and moved a minute back, and the exact cut is made on the timestamps Jira returns. Keys go into the JQL only when they look like keys (PROJECT-123); anything else is reported, not queried. A key that no longer exists fails a JQL query outright, so the keys are then read one by one and the dead ones reported under missing. get_comments takes order (oldest, the default and the previous behaviour, or newest) and start_at, returns total and next_start_at to page, and adds author_id (accountId on Cloud, key or name On-Prem). It read only the first page, oldest first, so past 50 comments the newest were never returned. Its body text keeps line breaks and decodes HTML entities: an & came out as &. | 1.3.0 — create_issue and edit_issue offer the Jira site's own values: the project, the issue type of that project and the priority are picked from a list the playbook editor loads from the instance, as is link_issues' link type. Three read commands feed them — list_projects, list_issue_types and list_priorities — and each field keeps accepting a typed value or an expression. 1.2.0 — Declare the risk tier on the 11 commands that carried none: 8 safe_write, 3 destructive. An unset risk is read as destructive by the agent approval gate, so the 8 that only annotate sat behind the same prompt as the ones that change the estate. | 1.1.0 — New command list_filters: the saved filters a Jira instance already holds, with their id, JQL and owner, plus the exact clause to paste into a fetch. A saved filter could always be ingested — filter = 12345 is ordinary JQL — but nothing told you the id, so the workflow was invisible. get_incidents and query_issues now mention it in their help. 1.0.2 — Command field help rewritten: every input now says what to type, in what format, and which command finds the value, instead of restating the field name. get_incidents explains that the fetch adds its own created >= watermark clause and ORDER BY, that the watermark is filled by the alert rule, and that a run does not page. Assignee, board, sprint, comment and attachment fields name the command that returns their id. 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
|
||||
@@ -73,6 +73,16 @@ commands:
|
||||
offset: { type: number, description: "Index of the first row, for paging: 0, then 50, then 100…" }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
- id: watch_issues
|
||||
name: jira-watch-issues
|
||||
description: "Report what changed on a set of issues since an instant: for each issue updated since, its status, the comments added since and its status transitions. Also returns self, the account the instance runs as, and flags its own comments. Built for Riposte's ITSM watch, which follows the ticket recorded on a case."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
keys: { type: array, description: "Issue keys to watch, at most 50, e.g. [SOC-1423, SOC-1430]. A comma-separated string is read too. A value that is not a key (PROJECT-123) is not queried and comes back under invalid_keys." }
|
||||
since: { type: string, description: "Only changes at or after this instant: ISO8601 like 2026-10-13T12:30:00Z, or an epoch in seconds or milliseconds. Empty means a first look: every issue, with its latest page of comments, to record as already seen." }
|
||||
required: [keys]
|
||||
outputs_schema: { properties: {} }
|
||||
- id: get_issue
|
||||
name: jira-get-issue
|
||||
description: "Retrieve a single issue by ID or key (flattened)."
|
||||
@@ -193,7 +203,9 @@ commands:
|
||||
properties:
|
||||
issue_id: { type: string, description: "Numeric issue ID, e.g. 10042 — the id field the fetch returns. Give this or issue_key." }
|
||||
issue_key: { type: string, description: "Issue key as shown in Jira, e.g. SOC-1423. Give this or issue_id." }
|
||||
limit: { type: number, description: "Comments to return, newest last, default 50." }
|
||||
limit: { type: number, description: "Comments per page, default 50." }
|
||||
order: { type: string, enum: [oldest, newest], description: "oldest (default) returns the first comments first; newest returns the latest first, which is what you want on a long thread." }
|
||||
start_at: { type: number, description: "Index of the first comment, for paging: 0, then the next_start_at the previous call returned. Empty for the first page." }
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
- id: edit_comment
|
||||
|
||||
@@ -142,24 +142,46 @@ def adf(text):
|
||||
"content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]}
|
||||
|
||||
|
||||
def _html_text(rendered):
|
||||
t = re.sub(r"(?i)<br\s*/?>", "\n", str(rendered))
|
||||
t = re.sub(r"(?i)</(p|div|li|h[1-6]|tr|blockquote|pre)>", "\n", t)
|
||||
t = re.sub(r"<[^>]+>", "", t)
|
||||
t = html.unescape(t)
|
||||
t = re.sub(r"[ \t\r\f\v]+\n", "\n", t)
|
||||
return re.sub(r"\n{3,}", "\n\n", t).strip()
|
||||
|
||||
|
||||
def _body_text(comment):
|
||||
rendered = comment.get("renderedBody")
|
||||
if rendered:
|
||||
return re.sub(r"<[^>]+>", "", str(rendered))
|
||||
return _html_text(rendered)
|
||||
raw_body = comment.get("body")
|
||||
if isinstance(raw_body, str):
|
||||
return raw_body
|
||||
return ""
|
||||
|
||||
|
||||
def _user_id(u):
|
||||
# Cloud identifies a user by accountId; On-Prem by key, then name.
|
||||
u = u or {}
|
||||
return str(u.get("accountId") or u.get("key") or u.get("name") or "")
|
||||
|
||||
|
||||
def main():
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
raw_ref = str(inputs.get("issue_id") or "") or str(inputs.get("issue_key") or "")
|
||||
ref = issue_ref(inputs)
|
||||
limit = inputs.get("limit") or 50
|
||||
order = str(inputs.get("order") or "oldest").strip().lower()
|
||||
if order not in ("oldest", "newest"):
|
||||
raise Exception("order is oldest or newest, not " + order)
|
||||
start_at = inputs.get("start_at")
|
||||
start_at = int(start_at) if str(start_at or "").strip() != "" else 0
|
||||
|
||||
res = request("GET", "/rest/api/" + _api() + "/issue/" + ref + "/comment",
|
||||
params={"maxResults": limit, "expand": "renderedBody"})
|
||||
params={"maxResults": limit, "startAt": start_at,
|
||||
"orderBy": "-created" if order == "newest" else "created",
|
||||
"expand": "renderedBody"})
|
||||
|
||||
comments = []
|
||||
for c in res.get("comments", []) or []:
|
||||
@@ -167,13 +189,15 @@ def main():
|
||||
"id": c.get("id", ""),
|
||||
"body": _body_text(c),
|
||||
"author": c.get("author", {}).get("displayName", ""),
|
||||
"author_id": _user_id(c.get("author")),
|
||||
"created": c.get("created", ""),
|
||||
"updated": c.get("updated", ""),
|
||||
"update_author": c.get("updateAuthor", {}).get("displayName", ""),
|
||||
})
|
||||
|
||||
print(json.dumps({"issue": raw_ref, "comments": comments}))
|
||||
|
||||
total = int(res.get("total") or 0)
|
||||
print(json.dumps({"issue": raw_ref, "comments": comments, "total": total,
|
||||
"next_start_at": start_at + len(comments) if start_at + len(comments) < total else None}))
|
||||
|
||||
try:
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import base64, html, json, os, re, sys, urllib.parse, urllib.request, urllib.error
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
def _cfg():
|
||||
return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}"))
|
||||
|
||||
|
||||
def _is_cloud():
|
||||
return bool(str(_cfg().get("cloud_id") or "").strip())
|
||||
|
||||
|
||||
def _api():
|
||||
return "3" if _is_cloud() else "2"
|
||||
|
||||
|
||||
def _base():
|
||||
cfg = _cfg()
|
||||
root = str(cfg.get("server_url") or "https://api.atlassian.com/ex/jira").rstrip("/")
|
||||
cloud_id = str(cfg.get("cloud_id") or "").strip()
|
||||
if not cloud_id and "api.atlassian.com" in root:
|
||||
# The Cloud gateway addresses a site by its cloud id; without one every
|
||||
# path below it is a dead link, and Jira answers with an HTML 404 that
|
||||
# says nothing about the cause.
|
||||
raise Exception(
|
||||
"server_url is the Jira Cloud gateway (" + root + ") but cloud_id is empty. "
|
||||
"Set cloud_id, found at admin.atlassian.com/s/<cloud_id>/users, or point "
|
||||
"server_url at your own Jira URL for On-Prem/Data Center."
|
||||
)
|
||||
if cloud_id and "api.atlassian.com" not in root:
|
||||
# The reverse mistake, and the harder one to see: against any other host
|
||||
# a cloud id is just a path segment, and every call 404s.
|
||||
raise Exception(
|
||||
"cloud_id is set but server_url (" + root + ") is not the Jira Cloud gateway. "
|
||||
"A cloud id addresses a site through https://api.atlassian.com/ex/jira only. "
|
||||
"Clear cloud_id for On-Prem/Data Center, or restore the gateway URL."
|
||||
)
|
||||
return root + "/" + cloud_id if cloud_id else root
|
||||
|
||||
|
||||
def _creds():
|
||||
# A credential pasted from a password manager often carries a trailing
|
||||
# newline; unstripped it corrupts the header and reads back as a wrong
|
||||
# password. Basic wins whenever the pair is complete: a token left behind
|
||||
# from another deployment must not outrank credentials just filled in.
|
||||
# A lone username, or a lone api_key, is not a pair.
|
||||
cfg = _cfg()
|
||||
user = str(cfg.get("username") or "").strip()
|
||||
key = str(cfg.get("api_key") or "").strip()
|
||||
pat = str(cfg.get("pat") or "").strip()
|
||||
mode = "basic" if (user and key) else ("pat" if pat else "")
|
||||
return mode, user, key, pat
|
||||
|
||||
|
||||
def _auth_method():
|
||||
# Named in a 401/403, where the useful question is which credential was sent.
|
||||
mode, user, key, pat = _creds()
|
||||
if mode == "basic":
|
||||
return "Basic authentication as " + user + (
|
||||
" (a personal access token is also configured and was ignored)" if pat else ""
|
||||
)
|
||||
if mode == "pat":
|
||||
half = ""
|
||||
if user or key:
|
||||
half = " (" + ("username" if user else "api_key") + " is set but its pair is "
|
||||
half += "empty, so Basic authentication was not used)"
|
||||
return "a personal access token" + half
|
||||
return "no credential"
|
||||
|
||||
|
||||
def _auth():
|
||||
mode, user, key, pat = _creds()
|
||||
if mode == "basic":
|
||||
cred = (user + ":" + key).encode("utf-8")
|
||||
return "Basic " + base64.b64encode(cred).decode("ascii")
|
||||
if mode == "pat":
|
||||
return "Bearer " + pat
|
||||
raise Exception(
|
||||
"No usable credential. Jira Cloud needs username (account email) together with "
|
||||
"api_key (API token); On-Prem/Data Center needs pat. "
|
||||
+ ("Only " + ("username" if user else "api_key") + " is set."
|
||||
if (user or key) else "None of them is set.")
|
||||
)
|
||||
|
||||
|
||||
def _summarize(raw, ctype):
|
||||
# An error page is a wall of markup around one sentence. Keep the <title>
|
||||
# and the readable text: the rest only makes a log impossible to read.
|
||||
text = raw.decode("utf-8", "replace") if isinstance(raw, bytes) else str(raw)
|
||||
if "json" in (ctype or "").lower():
|
||||
return text.strip()[:500]
|
||||
title = re.search(r"<title[^>]*>(.*?)</title>", text, re.S | re.I)
|
||||
body = re.sub(r"<(script|style|head)[^>]*>.*?</\1>", " ", text, flags=re.S | re.I)
|
||||
body = re.sub(r"<[^>]+>", " ", body)
|
||||
out = (title.group(1) + " \u2014 " if title else "") + body
|
||||
return re.sub(r"\s+", " ", html.unescape(out)).strip()[:500]
|
||||
|
||||
|
||||
def request(method, path, params=None, body=None, full_url=None, raw=False):
|
||||
url = full_url or (_base() + path)
|
||||
q = {k: str(x) for k, x in (params or {}).items() if x not in (None, "")}
|
||||
if q:
|
||||
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(q)
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
headers = {"Accept": "application/json", "Authorization": _auth()}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=90) as r:
|
||||
content = r.read()
|
||||
if raw:
|
||||
return content
|
||||
if not content:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(content)
|
||||
except ValueError:
|
||||
# A 2xx that is not JSON is a login page, an SSO redirect or a proxy
|
||||
# answering in place of Jira. The URL the redirects landed on is
|
||||
# usually the whole diagnosis.
|
||||
landed = urllib.parse.urlsplit(r.geturl())
|
||||
ctype = r.headers.get("Content-Type") or "unknown"
|
||||
raise Exception(
|
||||
"Jira answered " + str(r.getcode()) + " with a non-JSON body at "
|
||||
+ landed.scheme + "://" + landed.netloc + landed.path
|
||||
+ " (content-type: " + ctype + "): " + _summarize(content, ctype)
|
||||
)
|
||||
|
||||
|
||||
# A key goes into the JQL as is, so it must look like one: PROJECT-123. Anything
|
||||
# else would let a field value written by a playbook rewrite the query.
|
||||
KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*-[0-9]+$")
|
||||
MAX_KEYS = 50
|
||||
COMMENT_PAGE = 50
|
||||
|
||||
|
||||
def parse_keys(raw):
|
||||
if isinstance(raw, str):
|
||||
raw = re.split(r"[\s,;]+", raw)
|
||||
keys, invalid = [], []
|
||||
for k in raw or []:
|
||||
k = str(k or "").strip()
|
||||
if not k:
|
||||
continue
|
||||
if KEY_RE.match(k):
|
||||
k = k.upper()
|
||||
if k not in keys:
|
||||
keys.append(k)
|
||||
else:
|
||||
invalid.append(k)
|
||||
return keys, invalid
|
||||
|
||||
|
||||
def parse_time(v):
|
||||
# Accepts what Riposte sends (ISO8601, epoch s or ms) and what Jira returns
|
||||
# (2026-10-13T14:32:05.123+0200 — an offset without a colon, which
|
||||
# fromisoformat only reads from Python 3.11 on).
|
||||
s = str(v or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
if s.isdigit():
|
||||
ts = int(s)
|
||||
if ts > 10 ** 12:
|
||||
ts = ts // 1000
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
s = s.replace("Z", "+00:00")
|
||||
m = re.match(r"^(.*[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)([+-]\d{2})(\d{2})$", s)
|
||||
if m:
|
||||
s = m.group(1) + m.group(2) + ":" + m.group(3)
|
||||
try:
|
||||
dt = datetime.fromisoformat(s)
|
||||
except ValueError:
|
||||
raise Exception("since is not a date: " + str(v) + " (expected ISO8601 or an epoch)")
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def jql_time(since, tz_name):
|
||||
# JQL reads a date in the time zone of the account running the query, and
|
||||
# only to the minute. Written in UTC it would be off by the account's
|
||||
# offset, and an update in that gap would never come back. So the instant
|
||||
# is converted to the account's zone and moved a minute back; the exact
|
||||
# cut is made below, on the timestamps Jira returns. Without a usable zone
|
||||
# the window is widened by a day instead of guessed.
|
||||
margin = timedelta(minutes=1)
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
local = since.astimezone(ZoneInfo(tz_name)) if tz_name else None
|
||||
except Exception:
|
||||
local = None
|
||||
if local is None:
|
||||
local = since.astimezone(timezone.utc)
|
||||
margin = timedelta(days=1)
|
||||
return (local - margin).strftime("%Y/%m/%d %H:%M")
|
||||
|
||||
|
||||
def html_text(rendered):
|
||||
t = re.sub(r"(?i)<br\s*/?>", "\n", str(rendered))
|
||||
t = re.sub(r"(?i)</(p|div|li|h[1-6]|tr|blockquote|pre)>", "\n", t)
|
||||
t = re.sub(r"<[^>]+>", "", t)
|
||||
t = html.unescape(t)
|
||||
t = re.sub(r"[ \t\r\f\v]+\n", "\n", t)
|
||||
return re.sub(r"\n{3,}", "\n\n", t).strip()
|
||||
|
||||
|
||||
def adf_text(node):
|
||||
# A Cloud body without its rendered form is Atlassian Document Format.
|
||||
if isinstance(node, str):
|
||||
return node
|
||||
if not isinstance(node, dict):
|
||||
return ""
|
||||
if node.get("type") == "text":
|
||||
return node.get("text") or ""
|
||||
if node.get("type") == "hardBreak":
|
||||
return "\n"
|
||||
inner = "".join(adf_text(c) for c in node.get("content") or [])
|
||||
if node.get("type") in ("paragraph", "heading", "listItem", "codeBlock", "blockquote"):
|
||||
inner += "\n"
|
||||
return inner
|
||||
|
||||
|
||||
def body_text(comment):
|
||||
rendered = comment.get("renderedBody")
|
||||
if rendered:
|
||||
return html_text(rendered)
|
||||
raw = comment.get("body")
|
||||
if isinstance(raw, str):
|
||||
return raw.strip()
|
||||
return adf_text(raw).strip()
|
||||
|
||||
|
||||
def user_id(u):
|
||||
# Cloud identifies a user by accountId; On-Prem by key, then name.
|
||||
u = u or {}
|
||||
return str(u.get("accountId") or u.get("key") or u.get("name") or "")
|
||||
|
||||
|
||||
def user_name(u):
|
||||
u = u or {}
|
||||
return u.get("displayName") or u.get("name") or u.get("emailAddress") or ""
|
||||
|
||||
|
||||
def browse_base():
|
||||
# The Cloud gateway is not where a person opens a ticket: the site URL is.
|
||||
if _is_cloud():
|
||||
try:
|
||||
info = request("GET", "/rest/api/3/serverInfo")
|
||||
return str(info.get("baseUrl") or "").rstrip("/")
|
||||
except Exception:
|
||||
return ""
|
||||
return str(_cfg().get("server_url") or "").rstrip("/")
|
||||
|
||||
|
||||
def search(keys, since_clause):
|
||||
jql = "key in (" + ",".join(keys) + ")"
|
||||
if since_clause:
|
||||
jql += ' AND updated >= "' + since_clause + '"'
|
||||
params = {"jql": jql, "maxResults": MAX_KEYS, "fields": "status,updated", "expand": "changelog"}
|
||||
if _is_cloud():
|
||||
return request("GET", "/rest/api/3/search/jql", params=params).get("issues") or []
|
||||
return request("GET", "/rest/api/2/search", params=params).get("issues") or []
|
||||
|
||||
|
||||
def fetch_one(key):
|
||||
try:
|
||||
return request("GET", "/rest/api/" + _api() + "/issue/" + urllib.parse.quote(key, safe=""),
|
||||
params={"fields": "status,updated", "expand": "changelog"})
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def find_issues(keys, since_clause):
|
||||
# JQL refuses the whole query when one key does not exist any more (a
|
||||
# ticket deleted or moved to another project). The keys are then read one
|
||||
# by one, so a single dead ticket does not blind the watch on the others.
|
||||
try:
|
||||
return search(keys, since_clause), []
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 400:
|
||||
raise
|
||||
found, missing = [], []
|
||||
for k in keys:
|
||||
issue = fetch_one(k)
|
||||
if issue is None:
|
||||
missing.append(k)
|
||||
continue
|
||||
found.append(issue)
|
||||
return found, missing
|
||||
|
||||
|
||||
def new_comments(key, since, me):
|
||||
# Newest first, page by page, until the comments are older than since. A
|
||||
# first look (no since) returns the newest page only: the caller records
|
||||
# them as seen and announces nothing.
|
||||
out, start = [], 0
|
||||
while True:
|
||||
res = request("GET", "/rest/api/" + _api() + "/issue/" + urllib.parse.quote(key, safe="") + "/comment",
|
||||
params={"orderBy": "-created", "startAt": start, "maxResults": COMMENT_PAGE,
|
||||
"expand": "renderedBody"})
|
||||
page = res.get("comments") or []
|
||||
page.sort(key=lambda c: parse_time(c.get("created")) or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True)
|
||||
older = False
|
||||
for c in page:
|
||||
created = parse_time(c.get("created"))
|
||||
if since is not None and created is not None and created < since:
|
||||
older = True
|
||||
break
|
||||
author = c.get("author") or {}
|
||||
aid = user_id(author)
|
||||
out.append({
|
||||
"id": str(c.get("id") or ""),
|
||||
"author_id": aid,
|
||||
"author": user_name(author),
|
||||
"created": c.get("created") or "",
|
||||
"updated": c.get("updated") or "",
|
||||
"body": body_text(c),
|
||||
"self": bool(aid) and aid == me,
|
||||
})
|
||||
total = int(res.get("total") or 0)
|
||||
start += len(page)
|
||||
if since is None or older or not page or start >= total:
|
||||
break
|
||||
out.reverse()
|
||||
return out
|
||||
|
||||
|
||||
def status_changes(issue, since):
|
||||
out = []
|
||||
for h in ((issue.get("changelog") or {}).get("histories") or []):
|
||||
at = parse_time(h.get("created"))
|
||||
if since is not None and at is not None and at < since:
|
||||
continue
|
||||
for item in h.get("items") or []:
|
||||
if item.get("field") != "status":
|
||||
continue
|
||||
out.append({
|
||||
"id": str(h.get("id") or ""),
|
||||
"at": h.get("created") or "",
|
||||
"author_id": user_id(h.get("author")),
|
||||
"author": user_name(h.get("author")),
|
||||
"from": item.get("fromString") or "",
|
||||
"to": item.get("toString") or "",
|
||||
})
|
||||
out.sort(key=lambda s: parse_time(s["at"]) or datetime.min.replace(tzinfo=timezone.utc))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
inputs = json.loads(os.environ.get("INTEGRATION_INPUTS", "{}"))
|
||||
keys, invalid = parse_keys(inputs.get("keys"))
|
||||
if len(keys) > MAX_KEYS:
|
||||
raise Exception(str(len(keys)) + " keys given, at most " + str(MAX_KEYS) + " per call: split them")
|
||||
since = parse_time(inputs.get("since"))
|
||||
|
||||
me = request("GET", "/rest/api/" + _api() + "/myself")
|
||||
me_id = user_id(me)
|
||||
result = {
|
||||
"self": {"id": me_id, "name": user_name(me)},
|
||||
"since": since.isoformat() if since else "",
|
||||
"issues": [],
|
||||
"missing": [],
|
||||
"invalid_keys": invalid,
|
||||
}
|
||||
if not keys:
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
clause = jql_time(since, me.get("timeZone")) if since else ""
|
||||
issues, missing = find_issues(keys, clause)
|
||||
result["missing"] = missing
|
||||
base = browse_base()
|
||||
returned = set()
|
||||
for issue in issues:
|
||||
key = issue.get("key") or ""
|
||||
returned.add(key.upper())
|
||||
f = issue.get("fields") or {}
|
||||
updated = parse_time(f.get("updated"))
|
||||
if since is not None and updated is not None and updated < since:
|
||||
continue
|
||||
result["issues"].append({
|
||||
"key": key,
|
||||
"id": str(issue.get("id") or ""),
|
||||
"updated": f.get("updated") or "",
|
||||
"status": (f.get("status") or {}).get("name") or "",
|
||||
"url": (base + "/browse/" + key) if base else "",
|
||||
"comments": new_comments(key, since, me_id),
|
||||
"status_changes": status_changes(issue, since),
|
||||
})
|
||||
if not since:
|
||||
# On a first look every key should come back; one that does not is
|
||||
# gone or out of the account's reach.
|
||||
result["missing"] = sorted(set(result["missing"]) | {k for k in keys if k not in returned})
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except urllib.error.HTTPError as e:
|
||||
ctype = e.headers.get("Content-Type", "") if e.headers else ""
|
||||
detail = _summarize(e.read(), ctype)
|
||||
if e.code in (401, 403):
|
||||
detail += " — the request was sent using " + _auth_method()
|
||||
print(json.dumps({"error": "HTTP " + str(e.code), "detail": detail}))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
File diff suppressed because one or more lines are too long
@@ -291,13 +291,20 @@ def investigate_uam(con, alert_id, days):
|
||||
'{ fieldId: "detectedAt" dateTimeRange: { start: %d end: %d } }' % (start.timestamp() * 1000, end.timestamp() * 1000),
|
||||
], SIBLING_FIELDS, 100))
|
||||
alerts = (sib or {}).get("alerts") or {}
|
||||
nodes = [e["node"] for e in alerts.get("edges") or [] if e.get("node") and e["node"].get("id") != alert_id]
|
||||
edges_all = [e["node"] for e in alerts.get("edges") or [] if e.get("node")]
|
||||
nodes = [n for n in edges_all if n.get("id") != alert_id]
|
||||
if sib is not None:
|
||||
elsewhere["same_host_other_alerts"] = compact({
|
||||
"total": max((alerts.get("totalCount") or 0) - 1, len(nodes)),
|
||||
"read": len(nodes),
|
||||
"by_name": group_count(nodes, lambda n: n.get("name"), lambda n: n.get("detectedAt") or ""),
|
||||
})
|
||||
# Counted from what was read, never from totalCount minus one: a
|
||||
# real run reported "1 other alert" with none to name, because the
|
||||
# console's total did not hold this alert. The console's own count
|
||||
# is only given when more alerts exist than were read.
|
||||
other = {"count": len(nodes),
|
||||
"by_name": group_count(nodes, lambda n: n.get("name"), lambda n: n.get("detectedAt") or "")}
|
||||
total = alerts.get("totalCount")
|
||||
if total is not None and total > len(edges_all):
|
||||
other["console_total_including_this_alert"] = total
|
||||
other["note"] = "more alerts than the 100 read"
|
||||
elsewhere["same_host_other_alerts"] = compact(other) if nodes else "none"
|
||||
if agent:
|
||||
elsewhere["same_host_threats"] = threats_summary(con, "threats_on_host", {
|
||||
"agentIds": agent.get("id"), "createdAt__gt": iso(start), "createdAt__lt": iso(end)})
|
||||
|
||||
Reference in New Issue
Block a user