Skip to content

Commit c4360b8

Browse files
authored
feat: expand ingestion across GitHub/Jira/Slack with live-demo wiring (#6)
* feat: expand ingestion across GitHub/Jira/Slack with live-demo wiring Progress log added: docs/ways-of-working/INGESTION_FEATURE_PROGRESS_2026-04-17.md Includes backend adapters/routes, frontend controls, shared contract updates, and test coverage for partial-failure resilience. Test results: - backend: cd backend && .venv/bin/python -m pytest -q -> 40 passed - frontend: cd frontend && npm run lint && npm run build -> passed * chore: align systems lane boundaries and drop cross-lane files from pr6
1 parent b5f1435 commit c4360b8

15 files changed

Lines changed: 1980 additions & 41 deletions

backend/app/api/routes/ingestion.py

Lines changed: 503 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import Any
5+
6+
import httpx
7+
8+
9+
class GitHubClientError(RuntimeError):
10+
pass
11+
12+
13+
class GitHubClient:
14+
def __init__(
15+
self,
16+
*,
17+
token: str,
18+
api_base_url: str = "https://api.github.com",
19+
timeout_seconds: float = 15.0,
20+
) -> None:
21+
self.token = token
22+
self.api_base_url = api_base_url.rstrip("/")
23+
self.timeout_seconds = timeout_seconds
24+
25+
@classmethod
26+
def from_env(cls) -> "GitHubClient":
27+
token = os.getenv("GITHUB_TOKEN", "").strip()
28+
api_base_url = os.getenv("GITHUB_API_BASE_URL", "https://api.github.com").strip()
29+
30+
if not token:
31+
raise GitHubClientError("GITHUB_TOKEN is not configured")
32+
33+
return cls(token=token, api_base_url=api_base_url or "https://api.github.com")
34+
35+
def fetch_issue(self, *, repository: str, issue_number: int) -> dict[str, Any]:
36+
normalized_repo = repository.strip()
37+
if not normalized_repo:
38+
raise GitHubClientError("repository is required")
39+
if issue_number <= 0:
40+
raise GitHubClientError("issue_number must be a positive integer")
41+
42+
url = f"{self.api_base_url}/repos/{normalized_repo}/issues/{issue_number}"
43+
headers = {
44+
"Authorization": f"Bearer {self.token}",
45+
"Accept": "application/vnd.github+json",
46+
"X-GitHub-Api-Version": "2022-11-28",
47+
}
48+
49+
try:
50+
with httpx.Client(timeout=self.timeout_seconds) as client:
51+
response = client.get(url, headers=headers)
52+
except httpx.HTTPError as exc:
53+
raise GitHubClientError(f"GitHub request failed: {exc}") from exc
54+
55+
if response.status_code in {401, 403}:
56+
raise GitHubClientError("GitHub issue fetch failed: authentication or permission error")
57+
if response.status_code == 404:
58+
raise GitHubClientError(f"GitHub issue {normalized_repo}#{issue_number} was not found")
59+
if response.status_code >= 500:
60+
raise GitHubClientError(f"GitHub service error while fetching {normalized_repo}#{issue_number}")
61+
if response.status_code >= 400:
62+
raise GitHubClientError(f"GitHub issue fetch failed with status {response.status_code}")
63+
64+
body = response.json()
65+
issue_url = str(body.get("html_url", "")).strip() or f"https://github.com/{normalized_repo}/issues/{issue_number}"
66+
title = str(body.get("title", "")).strip()
67+
state = str(body.get("state", "unknown")).strip() or "unknown"
68+
issue_body = str(body.get("body", "")).strip()
69+
70+
return {
71+
"repository": normalized_repo,
72+
"number": issue_number,
73+
"title": title,
74+
"state": state,
75+
"url": issue_url,
76+
"body": issue_body,
77+
}

backend/src/adapters/iris_client.py

Lines changed: 119 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ def __init__(
2424
self.verify_ssl = verify_ssl
2525
self.timeout_seconds = timeout_seconds
2626

27+
_SEVERITY_TO_ID = {
28+
"critical": 1,
29+
"high": 2,
30+
"medium": 3,
31+
"low": 4,
32+
}
33+
2734
@classmethod
2835
def from_env(cls) -> "IrisClient":
2936
base_url = os.getenv("IRIS_BASE_URL", "").strip()
@@ -44,6 +51,43 @@ def _headers(self) -> dict[str, str]:
4451
"Content-Type": "application/json",
4552
}
4653

54+
def _normalize_case_payload(
55+
self,
56+
*,
57+
case_payload: dict[str, Any],
58+
fallback_case_id: str,
59+
fallback_case_name: str,
60+
fallback_description: str,
61+
fallback_severity: str,
62+
fallback_tags: list[str] | None = None,
63+
) -> dict[str, Any]:
64+
case_id = str(case_payload.get("case_id", case_payload.get("id", fallback_case_id)))
65+
return {
66+
"source_system": "iris",
67+
"case_id": case_id,
68+
"report_id": str(case_payload.get("report_id", case_payload.get("id", case_id))),
69+
"report_url": case_payload.get("report_url") or f"{self.base_url}/case/{case_id}",
70+
"ingested_at": case_payload.get("modification_date") or case_payload.get("created_at"),
71+
"case_name": case_payload.get("case_name") or case_payload.get("name") or fallback_case_name,
72+
"short_description": case_payload.get("case_description")
73+
or case_payload.get("description")
74+
or fallback_description,
75+
"severity": str(case_payload.get("severity", fallback_severity)),
76+
"tags": case_payload.get("tags") or fallback_tags or [],
77+
"iocs": case_payload.get("iocs", []),
78+
"timeline": case_payload.get("timeline", []),
79+
}
80+
81+
def _severity_id_from_label(self, severity: str) -> int:
82+
normalized = severity.strip().lower()
83+
if normalized in self._SEVERITY_TO_ID:
84+
return self._SEVERITY_TO_ID[normalized]
85+
86+
if normalized.isdigit() and int(normalized) > 0:
87+
return int(normalized)
88+
89+
return self._SEVERITY_TO_ID["medium"]
90+
4791
def _extract_case_payload(self, payload: Any, case_id: str) -> dict[str, Any]:
4892
if isinstance(payload, dict):
4993
data = payload.get("data", payload)
@@ -77,25 +121,83 @@ def fetch_case(self, case_id: str) -> dict[str, Any]:
77121

78122
payload = response.json()
79123
case_payload = self._extract_case_payload(payload, case_id)
80-
return {
81-
"source_system": "iris",
82-
"case_id": str(case_payload.get("case_id", case_payload.get("id", case_id))),
83-
"report_id": str(case_payload.get("report_id", case_payload.get("id", case_id))),
84-
"report_url": case_payload.get("report_url") or f"{self.base_url}/case/{case_id}",
85-
"ingested_at": case_payload.get("modification_date") or case_payload.get("created_at"),
86-
"case_name": case_payload.get("case_name")
87-
or case_payload.get("name")
88-
or f"IRIS Case {case_id}",
89-
"short_description": case_payload.get("case_description")
90-
or case_payload.get("description")
91-
or "No case description provided.",
92-
"severity": str(case_payload.get("severity", "unknown")),
93-
"tags": case_payload.get("tags", []),
94-
"iocs": case_payload.get("iocs", []),
95-
"timeline": case_payload.get("timeline", []),
96-
}
124+
return self._normalize_case_payload(
125+
case_payload=case_payload,
126+
fallback_case_id=case_id,
127+
fallback_case_name=f"IRIS Case {case_id}",
128+
fallback_description="No case description provided.",
129+
fallback_severity="unknown",
130+
)
97131
except (httpx.HTTPError, ValueError, IrisClientError) as exc:
98132
last_error = str(exc)
99133
continue
100134

101135
raise IrisClientError(f"Failed to fetch case {case_id} from IRIS: {last_error or 'unknown error'}")
136+
137+
def create_incident(
138+
self,
139+
*,
140+
case_name: str,
141+
case_description: str,
142+
severity: str = "medium",
143+
tags: list[str] | None = None,
144+
case_customer: int = 1,
145+
case_soc_id: str = "",
146+
classification_id: int | None = None,
147+
case_template_id: str | None = None,
148+
custom_attributes: dict[str, Any] | None = None,
149+
) -> dict[str, Any]:
150+
normalized_name = case_name.strip()
151+
normalized_description = case_description.strip()
152+
if not normalized_name:
153+
raise IrisClientError("case_name must be provided")
154+
if not normalized_description:
155+
raise IrisClientError("case_description must be provided")
156+
157+
payload: dict[str, Any] = {
158+
"case_name": normalized_name,
159+
"case_description": normalized_description,
160+
"case_customer": case_customer,
161+
"case_soc_id": case_soc_id,
162+
"severity_id": self._severity_id_from_label(severity),
163+
}
164+
165+
if tags:
166+
payload["case_tags"] = ",".join(item.strip() for item in tags if item.strip())
167+
if classification_id is not None:
168+
payload["classification_id"] = classification_id
169+
if case_template_id:
170+
payload["case_template_id"] = str(case_template_id)
171+
if custom_attributes is not None:
172+
payload["custom_attributes"] = custom_attributes
173+
174+
endpoints: list[tuple[str, str]] = [
175+
("POST", "/manage/cases/add"),
176+
]
177+
178+
last_error: str | None = None
179+
with httpx.Client(timeout=self.timeout_seconds, verify=self.verify_ssl) as client:
180+
for method, path in endpoints:
181+
url = f"{self.base_url}{path}"
182+
try:
183+
response = client.request(method=method, url=url, json=payload, headers=self._headers())
184+
if response.status_code >= 400:
185+
last_error = f"{method} {path} returned {response.status_code}"
186+
continue
187+
188+
body = response.json()
189+
case_payload = self._extract_case_payload(body, case_id="new")
190+
created_case_id = str(case_payload.get("case_id", case_payload.get("id", "new")))
191+
return self._normalize_case_payload(
192+
case_payload=case_payload,
193+
fallback_case_id=created_case_id,
194+
fallback_case_name=normalized_name,
195+
fallback_description=normalized_description,
196+
fallback_severity=severity,
197+
fallback_tags=tags,
198+
)
199+
except (httpx.HTTPError, ValueError, IrisClientError) as exc:
200+
last_error = str(exc)
201+
continue
202+
203+
raise IrisClientError(f"Failed to create IRIS incident: {last_error or 'unknown error'}")
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import re
5+
from typing import Any
6+
7+
import httpx
8+
9+
10+
class JiraClientError(RuntimeError):
11+
pass
12+
13+
14+
JIRA_ISSUE_KEY_PATTERN = re.compile(r"^[A-Z][A-Z0-9]*-\d+$")
15+
16+
17+
class JiraClient:
18+
def __init__(
19+
self,
20+
*,
21+
base_url: str,
22+
email: str,
23+
api_token: str,
24+
timeout_seconds: float = 15.0,
25+
) -> None:
26+
self.base_url = base_url.rstrip("/")
27+
self.email = email
28+
self.api_token = api_token
29+
self.timeout_seconds = timeout_seconds
30+
31+
@classmethod
32+
def from_env(cls) -> "JiraClient":
33+
base_url = os.getenv("JIRA_BASE_URL", "").strip()
34+
email = os.getenv("JIRA_EMAIL", "").strip()
35+
api_token = os.getenv("JIRA_API_TOKEN", "").strip()
36+
37+
if not base_url:
38+
raise JiraClientError("JIRA_BASE_URL is not configured")
39+
if not email:
40+
raise JiraClientError("JIRA_EMAIL is not configured")
41+
if not api_token:
42+
raise JiraClientError("JIRA_API_TOKEN is not configured")
43+
44+
return cls(base_url=base_url, email=email, api_token=api_token)
45+
46+
def fetch_issue(self, *, issue_key: str) -> dict[str, Any]:
47+
normalized_issue_key = issue_key.strip().upper()
48+
if not normalized_issue_key:
49+
raise JiraClientError("issue_key is required")
50+
if not JIRA_ISSUE_KEY_PATTERN.match(normalized_issue_key):
51+
raise JiraClientError(
52+
f"issue_key '{issue_key}' does not match expected format PROJECT-123"
53+
)
54+
55+
url = f"{self.base_url}/rest/api/3/issue/{normalized_issue_key}"
56+
auth = httpx.BasicAuth(username=self.email, password=self.api_token)
57+
params = {
58+
"fields": "summary,status,priority,assignee,description",
59+
}
60+
61+
try:
62+
with httpx.Client(timeout=self.timeout_seconds) as client:
63+
response = client.get(url, params=params, auth=auth)
64+
except httpx.HTTPError as exc:
65+
raise JiraClientError(f"Jira request failed: {exc}") from exc
66+
67+
if response.status_code in {401, 403}:
68+
raise JiraClientError("Jira issue fetch failed: authentication or permission error")
69+
if response.status_code == 404:
70+
raise JiraClientError(f"Jira issue {normalized_issue_key} was not found")
71+
if response.status_code >= 500:
72+
raise JiraClientError(f"Jira service error while fetching {normalized_issue_key}")
73+
if response.status_code >= 400:
74+
raise JiraClientError(f"Jira issue fetch failed with status {response.status_code}")
75+
76+
body = response.json()
77+
fields = body.get("fields", {})
78+
if not isinstance(fields, dict):
79+
fields = {}
80+
81+
status_block = fields.get("status", {})
82+
priority_block = fields.get("priority", {})
83+
assignee_block = fields.get("assignee", {})
84+
85+
status_name = str(status_block.get("name", "")) if isinstance(status_block, dict) else ""
86+
priority_name = str(priority_block.get("name", "")) if isinstance(priority_block, dict) else ""
87+
assignee_name = str(assignee_block.get("displayName", "")) if isinstance(assignee_block, dict) else ""
88+
description = fields.get("description")
89+
90+
return {
91+
"key": normalized_issue_key,
92+
"summary": str(fields.get("summary", "")),
93+
"status": status_name,
94+
"priority": priority_name,
95+
"assignee": assignee_name,
96+
"description": description,
97+
"url": f"{self.base_url}/browse/{normalized_issue_key}",
98+
}

0 commit comments

Comments
 (0)