evidentia-integrations 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ # v0.4.0 — frontend build output lands in the Python package's static
2
+ # directory at wheel-assembly time via the hatchling build hook. The
3
+ # .gitkeep file in static/ is tracked; everything else is regenerated.
4
+ packages/evidentia-api/src/evidentia_api/static/assets/
5
+ packages/evidentia-api/src/evidentia_api/static/index.html
6
+ packages/evidentia-api/src/evidentia_api/static/*.js
7
+ packages/evidentia-api/src/evidentia_api/static/*.css
8
+
9
+ # Python
10
+ __pycache__/
11
+ *.py[cod]
12
+ *$py.class
13
+ *.so
14
+ .Python
15
+ build/
16
+ develop-eggs/
17
+ dist/
18
+ downloads/
19
+ eggs/
20
+ .eggs/
21
+ lib/
22
+ lib64/
23
+ parts/
24
+ sdist/
25
+ var/
26
+ wheels/
27
+ # NB: `lib/` and `lib64/` above would otherwise also match
28
+ # packages/evidentia-ui/src/lib/ (TypeScript utils). Scope to top-level
29
+ # only — there's no real Python-venv lib/ we'd fail to ignore because
30
+ # .venv/ and venv/ below cover that case.
31
+ !packages/evidentia-ui/src/lib/
32
+ *.egg-info/
33
+ .installed.cfg
34
+ *.egg
35
+ MANIFEST
36
+
37
+ # Virtual environments
38
+ .venv/
39
+ venv/
40
+ ENV/
41
+ env/
42
+
43
+ # uv
44
+ # NOTE: uv.lock is committed for reproducible builds.
45
+ # https://docs.astral.sh/uv/concepts/projects/sync/#locking-dependencies
46
+
47
+ # Testing
48
+ .pytest_cache/
49
+ .coverage
50
+ .coverage.*
51
+ htmlcov/
52
+ .tox/
53
+ .cache
54
+ coverage.xml
55
+ *.cover
56
+ .hypothesis/
57
+
58
+ # mypy
59
+ .mypy_cache/
60
+ .dmypy.json
61
+ dmypy.json
62
+
63
+ # Ruff
64
+ .ruff_cache/
65
+
66
+ # IDE
67
+ .vscode/
68
+ .idea/
69
+ *.swp
70
+ *.swo
71
+ *~
72
+ .DS_Store
73
+
74
+ # Claude Code local state
75
+ .claude/
76
+
77
+ # Evidentia runtime — user project state (NOT bundled examples).
78
+ # `.controlbridge/` and `/controlbridge.yaml` are kept ignored for the
79
+ # lifetime of the shim (through v0.7.0) so legacy project workspaces
80
+ # authored against v0.1.0 – v0.5.0 don't start leaking into git.
81
+ .evidentia/
82
+ .controlbridge/
83
+ /evidentia.yaml
84
+ /controlbridge.yaml
85
+ *.local.yaml
86
+ evidence/
87
+ reports/
88
+ risks/
89
+
90
+ # Generated reports from examples (keep source files, ignore generated ones)
91
+ examples/**/report.json
92
+ examples/**/report.csv
93
+ examples/**/report.md
94
+ examples/**/report.oscal.json
95
+ examples/**/risks.json
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: evidentia-integrations
3
+ Version: 0.6.0
4
+ Summary: Output integrations for Jira and ServiceNow
5
+ Project-URL: Homepage, https://github.com/allenfbyrd/evidentia
6
+ Project-URL: Repository, https://github.com/allenfbyrd/evidentia
7
+ Project-URL: Issues, https://github.com/allenfbyrd/evidentia/issues
8
+ Project-URL: Changelog, https://github.com/allenfbyrd/evidentia/blob/main/CHANGELOG.md
9
+ Author-email: Allen Byrd <allen@allenfbyrd.com>
10
+ License-Expression: Apache-2.0
11
+ Keywords: compliance,grc,jira,servicenow,ticketing
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Information Technology
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: evidentia-core<0.7.0,>=0.6.0
21
+ Requires-Dist: httpx>=0.27
22
+ Provides-Extra: all
23
+ Requires-Dist: jira>=3.8; extra == 'all'
24
+ Requires-Dist: pysnc>=1.1; extra == 'all'
25
+ Provides-Extra: jira
26
+ Requires-Dist: jira>=3.8; extra == 'jira'
27
+ Provides-Extra: servicenow
28
+ Requires-Dist: pysnc>=1.1; extra == 'servicenow'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # evidentia-integrations
32
+
33
+ Output integrations for [Evidentia](https://github.com/allenfbyrd/evidentia). Push gaps to ticketing systems and export reports to industry-standard formats.
34
+
35
+ ## Provides
36
+
37
+ - **Jira integration** — Create issues from ControlGap entries with framework-aware field population
38
+ - **ServiceNow integration** — Create GRC records and incidents from gaps and risks
39
+ - **OSCAL Assessment Results exporter** — Produce compliant OSCAL JSON for assessment reporting
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install evidentia-integrations[jira]
45
+ pip install evidentia-integrations[servicenow]
46
+ pip install evidentia-integrations[all]
47
+ ```
48
+
49
+ License: Apache 2.0
@@ -0,0 +1,19 @@
1
+ # evidentia-integrations
2
+
3
+ Output integrations for [Evidentia](https://github.com/allenfbyrd/evidentia). Push gaps to ticketing systems and export reports to industry-standard formats.
4
+
5
+ ## Provides
6
+
7
+ - **Jira integration** — Create issues from ControlGap entries with framework-aware field population
8
+ - **ServiceNow integration** — Create GRC records and incidents from gaps and risks
9
+ - **OSCAL Assessment Results exporter** — Produce compliant OSCAL JSON for assessment reporting
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install evidentia-integrations[jira]
15
+ pip install evidentia-integrations[servicenow]
16
+ pip install evidentia-integrations[all]
17
+ ```
18
+
19
+ License: Apache 2.0
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "evidentia-integrations"
3
+ version = "0.6.0"
4
+ description = "Output integrations for Jira and ServiceNow"
5
+ readme = "README.md"
6
+ authors = [{name = "Allen Byrd", email = "allen@allenfbyrd.com"}]
7
+ license = "Apache-2.0"
8
+ requires-python = ">=3.12"
9
+ keywords = ["grc", "compliance", "jira", "servicenow", "ticketing"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Information Technology",
13
+ "License :: OSI Approved :: Apache Software License",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Topic :: System :: Systems Administration",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "evidentia-core>=0.6.0,<0.7.0",
21
+ "httpx>=0.27",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ jira = ["jira>=3.8"]
26
+ servicenow = ["pysnc>=1.1"]
27
+ all = ["jira>=3.8", "pysnc>=1.1"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/allenfbyrd/evidentia"
31
+ Repository = "https://github.com/allenfbyrd/evidentia"
32
+ Issues = "https://github.com/allenfbyrd/evidentia/issues"
33
+ Changelog = "https://github.com/allenfbyrd/evidentia/blob/main/CHANGELOG.md"
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/evidentia_integrations"]
41
+
42
+ [tool.uv.sources]
43
+ evidentia-core = { workspace = true }
@@ -0,0 +1,9 @@
1
+ """Evidentia integrations: output integrations for Jira, ServiceNow, and OSCAL exporters."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _pkg_version
5
+
6
+ try:
7
+ __version__ = _pkg_version("evidentia-integrations")
8
+ except PackageNotFoundError: # pragma: no cover
9
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,66 @@
1
+ """Jira output integration — push gaps as issues + bidirectional status sync.
2
+
3
+ Public surface (imports from ``evidentia_integrations.jira``):
4
+
5
+ - :class:`JiraClient` — httpx-based REST client; one instance per server.
6
+ - :class:`JiraConfig` — typed configuration (base URL, project key, etc.).
7
+ Credentials come from environment variables; the config object never
8
+ carries the API token in its serialized form.
9
+ - :class:`JiraIssue` — response shape from Jira's REST v3 API, narrowed
10
+ to the fields we actually care about.
11
+ - :class:`JiraApiError`, :class:`JiraMappingError` — exception types.
12
+ - :func:`gap_to_create_request` / :func:`jira_status_to_gap_status` —
13
+ pure functions exposing the mapping between Evidentia gaps and
14
+ Jira issues. Easy to unit-test without a live server.
15
+ - :func:`push_gap_to_jira` / :func:`sync_gap_from_jira` — gap-level
16
+ helpers that combine client + mapper.
17
+ - :func:`push_open_gaps` / :func:`sync_report` — batch wrappers over a
18
+ :class:`GapAnalysisReport`. Used by the CLI and REST endpoints.
19
+
20
+ v0.5.0 uses the Jira Cloud REST API v3 (``/rest/api/3``). Server-hosted
21
+ Jira is untested; most of the same endpoints exist but the workflow
22
+ configuration + auth story diverge. File an issue if you need Server
23
+ support.
24
+ """
25
+
26
+ from evidentia_integrations.jira.client import (
27
+ JiraApiError,
28
+ JiraClient,
29
+ JiraIssue,
30
+ )
31
+ from evidentia_integrations.jira.config import JiraConfig
32
+ from evidentia_integrations.jira.mapper import (
33
+ GAP_STATUS_TO_JIRA_STATUS,
34
+ JIRA_STATUS_TO_GAP_STATUS,
35
+ JiraMappingError,
36
+ gap_to_create_request,
37
+ jira_status_to_gap_status,
38
+ )
39
+ from evidentia_integrations.jira.sync import (
40
+ JiraSyncAction,
41
+ JiraSyncOutcome,
42
+ JiraSyncResult,
43
+ push_gap_to_jira,
44
+ push_open_gaps,
45
+ sync_gap_from_jira,
46
+ sync_report,
47
+ )
48
+
49
+ __all__ = [
50
+ "GAP_STATUS_TO_JIRA_STATUS",
51
+ "JIRA_STATUS_TO_GAP_STATUS",
52
+ "JiraApiError",
53
+ "JiraClient",
54
+ "JiraConfig",
55
+ "JiraIssue",
56
+ "JiraMappingError",
57
+ "JiraSyncAction",
58
+ "JiraSyncOutcome",
59
+ "JiraSyncResult",
60
+ "gap_to_create_request",
61
+ "jira_status_to_gap_status",
62
+ "push_gap_to_jira",
63
+ "push_open_gaps",
64
+ "sync_gap_from_jira",
65
+ "sync_report",
66
+ ]
@@ -0,0 +1,288 @@
1
+ """httpx-based Jira Cloud REST client.
2
+
3
+ We use ``httpx`` directly rather than the official ``jira`` PyPI SDK to
4
+ keep the dependency footprint small (Pillow, defusedxml, keyring get
5
+ pulled in otherwise) and because our three use cases — create issue,
6
+ get issue, transition issue — are well within REST reach.
7
+
8
+ All methods are synchronous for v0.5.0. An async variant lands in v0.5.1
9
+ alongside the gap-batch-push UI flow (generates one issue per gap with
10
+ progress feedback — SSE-style on the GUI path).
11
+
12
+ **Secret handling.** The ``api_token`` from :class:`JiraConfig` is sent
13
+ as HTTP basic-auth's password field, never logged, never returned in
14
+ any method's output. Exceptions carry the HTTP status code + the Jira
15
+ ``errorMessages`` array but never echo the outbound headers.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import logging
22
+ from typing import Any
23
+
24
+ import httpx
25
+ from pydantic import BaseModel, ConfigDict, Field
26
+
27
+ from evidentia_integrations.jira.config import JiraConfig
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class JiraApiError(Exception):
33
+ """Raised when the Jira REST API returns a non-2xx response.
34
+
35
+ Carries the HTTP status code and the ``errorMessages`` array (or a
36
+ raw body excerpt if Jira returned something unexpected). Never
37
+ carries request headers — avoids accidentally leaking the
38
+ Authorization value into a log aggregator.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ message: str,
44
+ *,
45
+ status_code: int,
46
+ errors: list[str] | None = None,
47
+ body_excerpt: str | None = None,
48
+ ) -> None:
49
+ self.status_code = status_code
50
+ self.errors = errors or []
51
+ self.body_excerpt = body_excerpt
52
+ detail = f"[HTTP {status_code}] {message}"
53
+ if errors:
54
+ detail += f" -- {'; '.join(errors)}"
55
+ super().__init__(detail)
56
+
57
+
58
+ class JiraIssue(BaseModel):
59
+ """Typed Jira issue — narrowed to the fields Evidentia cares about."""
60
+
61
+ model_config = ConfigDict(extra="allow")
62
+
63
+ key: str = Field(description="Issue key, e.g. 'SEC-42'.")
64
+ id: str = Field(description="Internal numeric id.")
65
+ summary: str = Field(description="Issue summary.")
66
+ status_name: str = Field(
67
+ description="Current workflow status name, e.g. 'To Do', 'In Progress', 'Done'.",
68
+ )
69
+ status_category: str = Field(
70
+ description="Jira status category: 'new', 'indeterminate', 'done', 'undefined'.",
71
+ )
72
+ url: str = Field(description="Public issue URL for browser access.")
73
+
74
+
75
+ class JiraClient:
76
+ """Thin Jira Cloud REST v3 client.
77
+
78
+ Usage::
79
+
80
+ cfg = JiraConfig.from_env()
81
+ client = JiraClient(cfg)
82
+ issue = client.create_issue(summary="...", description="...")
83
+ remote = client.get_issue(issue.key)
84
+ client.transition_issue(issue.key, target_status="Done")
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ config: JiraConfig,
90
+ *,
91
+ http: httpx.Client | None = None,
92
+ ) -> None:
93
+ """Build a client.
94
+
95
+ Parameters
96
+ ----------
97
+ config
98
+ Validated :class:`JiraConfig`.
99
+ http
100
+ Optional pre-built httpx client — tests inject one backed by
101
+ ``httpx.MockTransport`` so they don't make network calls.
102
+ """
103
+ self._config = config
104
+ basic = base64.b64encode(
105
+ f"{config.email}:{config.api_token}".encode()
106
+ ).decode("ascii")
107
+ self._http = http or httpx.Client(
108
+ base_url=config.base_url,
109
+ headers={
110
+ "Authorization": f"Basic {basic}",
111
+ "Accept": "application/json",
112
+ "Content-Type": "application/json",
113
+ },
114
+ timeout=config.timeout_seconds,
115
+ )
116
+
117
+ @property
118
+ def config(self) -> JiraConfig:
119
+ """Expose the config for callers (tests, CLI, etc.)."""
120
+ return self._config
121
+
122
+ def close(self) -> None:
123
+ """Close the underlying HTTP client."""
124
+ self._http.close()
125
+
126
+ def __enter__(self) -> JiraClient:
127
+ return self
128
+
129
+ def __exit__(self, *_: object) -> None:
130
+ self.close()
131
+
132
+ # ── Low-level request ───────────────────────────────────────────
133
+
134
+ def _request(
135
+ self,
136
+ method: str,
137
+ path: str,
138
+ *,
139
+ json: dict[str, Any] | None = None,
140
+ params: dict[str, str] | None = None,
141
+ ) -> dict[str, Any]:
142
+ """Issue a request + raise :class:`JiraApiError` on non-2xx."""
143
+ try:
144
+ response = self._http.request(
145
+ method, path, json=json, params=params
146
+ )
147
+ except httpx.HTTPError as e:
148
+ raise JiraApiError(
149
+ f"Jira request failed: {e}", status_code=0
150
+ ) from e
151
+
152
+ if response.status_code == 204:
153
+ return {}
154
+
155
+ try:
156
+ body = response.json()
157
+ except ValueError:
158
+ body = None
159
+
160
+ if not 200 <= response.status_code < 300:
161
+ errors: list[str] = []
162
+ if isinstance(body, dict):
163
+ errors = list(body.get("errorMessages", []))
164
+ error_field = body.get("errors")
165
+ if isinstance(error_field, dict):
166
+ errors.extend(f"{k}: {v}" for k, v in error_field.items())
167
+ excerpt = (
168
+ response.text[:200] + ("..." if len(response.text) > 200 else "")
169
+ if not errors
170
+ else None
171
+ )
172
+ raise JiraApiError(
173
+ f"{method.upper()} {path}",
174
+ status_code=response.status_code,
175
+ errors=errors,
176
+ body_excerpt=excerpt,
177
+ )
178
+
179
+ return body if isinstance(body, dict) else {}
180
+
181
+ # ── High-level operations ───────────────────────────────────────
182
+
183
+ def test_connection(self) -> dict[str, str]:
184
+ """Verify credentials + project access.
185
+
186
+ Returns the authenticated user's display name + the project
187
+ name. Raises :class:`JiraApiError` if either call fails.
188
+ """
189
+ me = self._request("GET", "/rest/api/3/myself")
190
+ project = self._request(
191
+ "GET", f"/rest/api/3/project/{self._config.project_key}"
192
+ )
193
+ return {
194
+ "user": str(me.get("displayName") or me.get("emailAddress") or "unknown"),
195
+ "project_key": self._config.project_key,
196
+ "project_name": str(project.get("name") or ""),
197
+ "base_url": self._config.base_url,
198
+ }
199
+
200
+ def create_issue(
201
+ self,
202
+ *,
203
+ summary: str,
204
+ description: str,
205
+ labels: list[str] | None = None,
206
+ extra_fields: dict[str, Any] | None = None,
207
+ ) -> JiraIssue:
208
+ """Create a new issue in the configured project.
209
+
210
+ ``description`` uses Jira's Atlassian Document Format (ADF) — we
211
+ wrap a plain-text paragraph automatically. Callers who want
212
+ richer formatting pass the ADF dict via ``extra_fields``.
213
+ """
214
+ adf_description: dict[str, Any] = {
215
+ "type": "doc",
216
+ "version": 1,
217
+ "content": [
218
+ {
219
+ "type": "paragraph",
220
+ "content": [{"type": "text", "text": description}],
221
+ }
222
+ ],
223
+ }
224
+ fields: dict[str, Any] = {
225
+ "project": {"key": self._config.project_key},
226
+ "issuetype": {"name": self._config.issue_type},
227
+ "summary": summary,
228
+ "description": adf_description,
229
+ "labels": labels or [],
230
+ }
231
+ if extra_fields:
232
+ fields.update(extra_fields)
233
+ result = self._request("POST", "/rest/api/3/issue", json={"fields": fields})
234
+ key = str(result["key"])
235
+ return self.get_issue(key)
236
+
237
+ def get_issue(self, key: str) -> JiraIssue:
238
+ """Fetch a single issue by key."""
239
+ body = self._request(
240
+ "GET",
241
+ f"/rest/api/3/issue/{key}",
242
+ params={"fields": "summary,status"},
243
+ )
244
+ fields = body.get("fields") or {}
245
+ status = fields.get("status") or {}
246
+ status_name = str(status.get("name") or "unknown")
247
+ status_category = str(
248
+ (status.get("statusCategory") or {}).get("key") or "undefined"
249
+ )
250
+ return JiraIssue(
251
+ key=str(body["key"]),
252
+ id=str(body["id"]),
253
+ summary=str(fields.get("summary") or ""),
254
+ status_name=status_name,
255
+ status_category=status_category,
256
+ url=f"{self._config.base_url}/browse/{body['key']}",
257
+ )
258
+
259
+ def list_transitions(self, key: str) -> list[dict[str, Any]]:
260
+ """Return the transitions currently available on this issue."""
261
+ body = self._request("GET", f"/rest/api/3/issue/{key}/transitions")
262
+ transitions = body.get("transitions", [])
263
+ return [t for t in transitions if isinstance(t, dict)]
264
+
265
+ def transition_issue(self, key: str, *, target_status: str) -> None:
266
+ """Transition the issue to a target workflow status by name.
267
+
268
+ Looks up the available transitions for this issue, finds one
269
+ whose ``to.name`` matches ``target_status`` case-insensitively,
270
+ and POSTs to ``/transitions`` with its id. Raises
271
+ :class:`JiraApiError` if no matching transition exists (e.g.
272
+ the workflow doesn't allow that move from the current state).
273
+ """
274
+ target_lower = target_status.lower()
275
+ for tr in self.list_transitions(key):
276
+ to_block = tr.get("to") or {}
277
+ if str(to_block.get("name", "")).lower() == target_lower:
278
+ self._request(
279
+ "POST",
280
+ f"/rest/api/3/issue/{key}/transitions",
281
+ json={"transition": {"id": str(tr["id"])}},
282
+ )
283
+ return
284
+ raise JiraApiError(
285
+ f"No transition to status {target_status!r} available from this issue's "
286
+ f"current state",
287
+ status_code=409,
288
+ )
@@ -0,0 +1,122 @@
1
+ """Typed Jira client configuration.
2
+
3
+ Mirrors the minimal subset of Jira configuration we need for gap-push +
4
+ status-sync workflows. Secrets come from environment variables; the
5
+ config object exists to centralize URL / project / mapping settings
6
+ that are fine to commit to ``evidentia.yaml``.
7
+
8
+ Env vars (resolved at :meth:`JiraConfig.from_env`):
9
+
10
+ - ``JIRA_BASE_URL`` — e.g. ``https://acme.atlassian.net``
11
+ - ``JIRA_EMAIL`` — the API user's email (basic-auth username)
12
+ - ``JIRA_API_TOKEN`` — the API user's token (basic-auth password).
13
+ Never logged; never returned in any Evidentia API response.
14
+ - ``JIRA_PROJECT_KEY`` — e.g. ``SEC`` or ``COMPLIANCE``.
15
+ - ``JIRA_ISSUE_TYPE`` — default ``Task``; commonly ``Task`` / ``Story`` /
16
+ ``Bug`` depending on org conventions.
17
+
18
+ CLI / API callers that need to override any of these pass kwargs to
19
+ :meth:`JiraConfig.from_env`.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+
26
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
27
+
28
+
29
+ class JiraConfig(BaseModel):
30
+ """Typed Jira client configuration.
31
+
32
+ The ``api_token`` field is stored on the in-memory instance so the
33
+ client can attach it to outbound requests, but Pydantic's
34
+ ``model_dump`` / ``model_dump_json`` never serialize it — it's marked
35
+ ``exclude=True`` so ``JiraConfig(...).model_dump()`` returns only the
36
+ safe, committable subset.
37
+ """
38
+
39
+ model_config = ConfigDict(str_strip_whitespace=True)
40
+
41
+ base_url: str = Field(
42
+ description=(
43
+ "Jira base URL, e.g. 'https://acme.atlassian.net'. No trailing slash."
44
+ ),
45
+ )
46
+ email: str = Field(description="Jira user email (basic-auth username).")
47
+ api_token: str = Field(
48
+ exclude=True,
49
+ description=(
50
+ "Jira API token (basic-auth password). Excluded from model_dump() "
51
+ "output so it never accidentally lands in a log or API response."
52
+ ),
53
+ )
54
+ project_key: str = Field(
55
+ description="Jira project key (all caps), e.g. 'SEC', 'COMPLIANCE'.",
56
+ )
57
+ issue_type: str = Field(
58
+ default="Task",
59
+ description="Jira issue type to create — Task / Story / Bug / etc.",
60
+ )
61
+ timeout_seconds: float = Field(
62
+ default=20.0,
63
+ gt=0,
64
+ description="httpx per-request timeout.",
65
+ )
66
+
67
+ @field_validator("base_url")
68
+ @classmethod
69
+ def _strip_trailing_slash(cls, v: str) -> str:
70
+ return v.rstrip("/")
71
+
72
+ @classmethod
73
+ def from_env(
74
+ cls,
75
+ *,
76
+ base_url: str | None = None,
77
+ email: str | None = None,
78
+ api_token: str | None = None,
79
+ project_key: str | None = None,
80
+ issue_type: str | None = None,
81
+ ) -> JiraConfig:
82
+ """Build a :class:`JiraConfig` from env vars + optional overrides.
83
+
84
+ Raises :class:`ValueError` listing every missing required field
85
+ so the CLI can print a single actionable error message instead
86
+ of a sequence of single-missing-var complaints.
87
+ """
88
+ resolved_url = base_url or os.environ.get("JIRA_BASE_URL")
89
+ resolved_email = email or os.environ.get("JIRA_EMAIL")
90
+ resolved_token = api_token or os.environ.get("JIRA_API_TOKEN")
91
+ resolved_project = project_key or os.environ.get("JIRA_PROJECT_KEY")
92
+ resolved_type = (
93
+ issue_type or os.environ.get("JIRA_ISSUE_TYPE") or "Task"
94
+ )
95
+
96
+ missing: list[str] = []
97
+ if not resolved_url:
98
+ missing.append("JIRA_BASE_URL")
99
+ if not resolved_email:
100
+ missing.append("JIRA_EMAIL")
101
+ if not resolved_token:
102
+ missing.append("JIRA_API_TOKEN")
103
+ if not resolved_project:
104
+ missing.append("JIRA_PROJECT_KEY")
105
+
106
+ if missing:
107
+ raise ValueError(
108
+ "Jira is not configured. Set these environment variables: "
109
+ + ", ".join(missing)
110
+ + ". See docs/integrations/jira.md."
111
+ )
112
+
113
+ # At this point mypy knows the optionals are populated, but the
114
+ # type checker can't narrow through the `missing` list branch.
115
+ assert resolved_url and resolved_email and resolved_token and resolved_project
116
+ return cls(
117
+ base_url=resolved_url,
118
+ email=resolved_email,
119
+ api_token=resolved_token,
120
+ project_key=resolved_project,
121
+ issue_type=resolved_type,
122
+ )
@@ -0,0 +1,170 @@
1
+ """Pure-functional mapping between Evidentia gaps and Jira issues.
2
+
3
+ Extracted from :mod:`evidentia_integrations.jira.client` so these
4
+ translations are easy to unit-test without HTTP mocking. The live
5
+ client composes these with REST calls.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Final
11
+
12
+ from evidentia_core.models.gap import ControlGap, GapSeverity, GapStatus
13
+
14
+
15
+ class JiraMappingError(Exception):
16
+ """Raised when a gap <-> Jira field translation fails validation."""
17
+
18
+
19
+ # ── Status mapping ───────────────────────────────────────────────────────
20
+
21
+ #: GapStatus -> Jira workflow status name (case-insensitive match on sync).
22
+ #: Accepted / Not-applicable map to ``Won't Do`` since Jira's default
23
+ #: workflow doesn't have a dedicated "accepted" transition — they're both
24
+ #: "won't remediate".
25
+ GAP_STATUS_TO_JIRA_STATUS: Final[dict[GapStatus, str]] = {
26
+ GapStatus.OPEN: "To Do",
27
+ GapStatus.IN_PROGRESS: "In Progress",
28
+ GapStatus.REMEDIATED: "Done",
29
+ GapStatus.ACCEPTED: "Won't Do",
30
+ GapStatus.NOT_APPLICABLE: "Won't Do",
31
+ }
32
+
33
+ #: Reverse mapping — Jira status (lowercased) -> GapStatus. Covers the
34
+ #: default Jira Cloud workflow names + a few common customizations
35
+ #: ("Closed", "Resolved") that teams often use.
36
+ JIRA_STATUS_TO_GAP_STATUS: Final[dict[str, GapStatus]] = {
37
+ "to do": GapStatus.OPEN,
38
+ "backlog": GapStatus.OPEN,
39
+ "open": GapStatus.OPEN,
40
+ "reopened": GapStatus.OPEN,
41
+ "in progress": GapStatus.IN_PROGRESS,
42
+ "in review": GapStatus.IN_PROGRESS,
43
+ "blocked": GapStatus.IN_PROGRESS,
44
+ "done": GapStatus.REMEDIATED,
45
+ "resolved": GapStatus.REMEDIATED,
46
+ "closed": GapStatus.REMEDIATED,
47
+ "complete": GapStatus.REMEDIATED,
48
+ "completed": GapStatus.REMEDIATED,
49
+ "won't do": GapStatus.ACCEPTED,
50
+ "won't fix": GapStatus.ACCEPTED,
51
+ "wontfix": GapStatus.ACCEPTED,
52
+ "cannot reproduce": GapStatus.ACCEPTED,
53
+ "declined": GapStatus.ACCEPTED,
54
+ }
55
+
56
+
57
+ # Severity -> Jira priority name. Jira's default priorities are
58
+ # "Highest / High / Medium / Low / Lowest". If a project customizes
59
+ # priorities the mapping can be overridden at :class:`JiraConfig`
60
+ # level in v0.5.1 — for now, most out-of-box projects accept these.
61
+ _SEVERITY_TO_PRIORITY: Final[dict[GapSeverity, str]] = {
62
+ GapSeverity.CRITICAL: "Highest",
63
+ GapSeverity.HIGH: "High",
64
+ GapSeverity.MEDIUM: "Medium",
65
+ GapSeverity.LOW: "Low",
66
+ GapSeverity.INFORMATIONAL: "Lowest",
67
+ }
68
+
69
+
70
+ def jira_status_to_gap_status(jira_status_name: str) -> GapStatus | None:
71
+ """Return the :class:`GapStatus` corresponding to a Jira status name.
72
+
73
+ Case-insensitive. Returns ``None`` for unknown statuses so callers
74
+ can decide how to handle unfamiliar workflow states (log + skip,
75
+ surface as an error, or prompt the user to extend the mapping).
76
+ """
77
+ return JIRA_STATUS_TO_GAP_STATUS.get(jira_status_name.strip().lower())
78
+
79
+
80
+ # ── Issue-creation payload ───────────────────────────────────────────────
81
+
82
+
83
+ def gap_to_create_request(gap: ControlGap) -> dict[str, object]:
84
+ """Build the kwargs for :meth:`JiraClient.create_issue` from a gap.
85
+
86
+ Produces a structured body with:
87
+ - ``summary``: ``"[{framework}] {control_id}: {control_title}"``
88
+ - ``description``: prose summary (severity, effort, gap description,
89
+ remediation guidance, cross-framework impact)
90
+ - ``labels``: the framework id + ``evidentia`` + severity
91
+ - ``extra_fields``: ``priority`` from gap severity.
92
+ """
93
+ if not gap.framework or not gap.control_id:
94
+ raise JiraMappingError(
95
+ "Gap is missing framework/control_id; cannot push to Jira."
96
+ )
97
+
98
+ summary = f"[{gap.framework}] {gap.control_id}: {gap.control_title}"
99
+ summary = summary[:250] # Jira cap is ~255 — stay conservative.
100
+
101
+ lines: list[str] = [
102
+ f"Control: {gap.framework}:{gap.control_id} — {gap.control_title}",
103
+ f"Severity: {_enum_value(gap.gap_severity)}",
104
+ f"Effort: {_enum_value(gap.implementation_effort).replace('_', ' ')}",
105
+ f"Priority score: {gap.priority_score:.2f}",
106
+ "",
107
+ "Gap:",
108
+ gap.gap_description or "(no description)",
109
+ "",
110
+ "Remediation guidance:",
111
+ gap.remediation_guidance or "(no guidance)",
112
+ ]
113
+ if gap.cross_framework_value:
114
+ lines.extend(
115
+ [
116
+ "",
117
+ "Cross-framework impact (closing this gap also satisfies):",
118
+ *(f" - {cf}" for cf in gap.cross_framework_value),
119
+ ]
120
+ )
121
+ if gap.equivalent_controls_in_inventory:
122
+ lines.extend(
123
+ [
124
+ "",
125
+ "Related controls in inventory:",
126
+ *(f" - {c}" for c in gap.equivalent_controls_in_inventory),
127
+ ]
128
+ )
129
+ lines.extend(
130
+ [
131
+ "",
132
+ f"Tracked by Evidentia gap id: {gap.id}",
133
+ ]
134
+ )
135
+
136
+ severity_str = _enum_value(gap.gap_severity)
137
+ labels = [
138
+ "evidentia",
139
+ gap.framework,
140
+ f"severity-{severity_str}",
141
+ f"effort-{_enum_value(gap.implementation_effort)}",
142
+ ]
143
+
144
+ priority_name = _SEVERITY_TO_PRIORITY.get(
145
+ gap.gap_severity
146
+ if isinstance(gap.gap_severity, GapSeverity)
147
+ else GapSeverity(gap.gap_severity) # type: ignore[arg-type]
148
+ )
149
+
150
+ extra_fields: dict[str, object] = {}
151
+ if priority_name:
152
+ extra_fields["priority"] = {"name": priority_name}
153
+
154
+ return {
155
+ "summary": summary,
156
+ "description": "\n".join(lines),
157
+ "labels": labels,
158
+ "extra_fields": extra_fields,
159
+ }
160
+
161
+
162
+ def _enum_value(value: object) -> str:
163
+ """Return the ``.value`` of an enum, or the string form otherwise.
164
+
165
+ Evidentia's Pydantic models use ``use_enum_values=True`` which
166
+ means loaded-from-JSON instances carry strings, while in-memory
167
+ instances carry the enum itself. Normalizing here lets the mapper
168
+ work identically on both paths.
169
+ """
170
+ return value.value if hasattr(value, "value") else str(value)
@@ -0,0 +1,358 @@
1
+ """Gap-to-Jira push + sync helpers.
2
+
3
+ Composes :class:`JiraClient` with the pure-functional mapper in
4
+ :mod:`.mapper` so callers (CLI, API) get a single function that takes
5
+ a gap and does the right thing:
6
+
7
+ - :func:`push_gap_to_jira` — create a Jira issue for a gap and stamp
8
+ the returned key onto ``gap.jira_issue_key``.
9
+ - :func:`sync_gap_from_jira` — read the linked Jira issue and update
10
+ ``gap.status`` if the Jira status maps to a known GapStatus.
11
+ - :func:`push_open_gaps` / :func:`sync_report` — batch wrappers that
12
+ iterate a :class:`GapAnalysisReport`.
13
+
14
+ All functions return structured :class:`JiraSyncOutcome` entries so
15
+ CLI / API callers can render per-gap results without a second pass.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from datetime import UTC, datetime
22
+ from enum import Enum
23
+
24
+ from evidentia_core.models.gap import (
25
+ ControlGap,
26
+ GapAnalysisReport,
27
+ GapStatus,
28
+ )
29
+ from pydantic import BaseModel, ConfigDict, Field
30
+
31
+ from evidentia_integrations.jira.client import (
32
+ JiraApiError,
33
+ JiraClient,
34
+ JiraIssue,
35
+ )
36
+ from evidentia_integrations.jira.mapper import (
37
+ JiraMappingError,
38
+ gap_to_create_request,
39
+ jira_status_to_gap_status,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ class JiraSyncAction(str, Enum):
46
+ """What happened to a single gap during a batch push/sync operation."""
47
+
48
+ CREATED = "created"
49
+ UPDATED = "updated"
50
+ SKIPPED = "skipped"
51
+ ERRORED = "errored"
52
+
53
+
54
+ class JiraSyncOutcome(BaseModel):
55
+ """One row of a batch push/sync result — one per gap processed."""
56
+
57
+ model_config = ConfigDict(extra="forbid")
58
+
59
+ gap_id: str
60
+ control_id: str
61
+ framework: str
62
+ action: JiraSyncAction
63
+ issue_key: str | None = Field(
64
+ default=None,
65
+ description=(
66
+ "Jira issue key after push/sync — populated on CREATED/UPDATED."
67
+ ),
68
+ )
69
+ issue_url: str | None = Field(default=None)
70
+ detail: str = Field(
71
+ description=(
72
+ "Human-readable one-line explanation — rendered verbatim in "
73
+ "CLI output and GUI toast messages."
74
+ ),
75
+ )
76
+ new_status: GapStatus | None = Field(
77
+ default=None,
78
+ description=(
79
+ "If the gap's status changed during sync, this is the new value. "
80
+ "Not mirrored for CREATED actions (those don't change status)."
81
+ ),
82
+ )
83
+
84
+
85
+ class JiraSyncResult(BaseModel):
86
+ """Aggregate result of a batch push or sync operation."""
87
+
88
+ model_config = ConfigDict(extra="forbid")
89
+
90
+ started_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
91
+ outcomes: list[JiraSyncOutcome] = Field(default_factory=list)
92
+
93
+ @property
94
+ def created(self) -> int:
95
+ return sum(1 for o in self.outcomes if o.action == JiraSyncAction.CREATED)
96
+
97
+ @property
98
+ def updated(self) -> int:
99
+ return sum(1 for o in self.outcomes if o.action == JiraSyncAction.UPDATED)
100
+
101
+ @property
102
+ def skipped(self) -> int:
103
+ return sum(1 for o in self.outcomes if o.action == JiraSyncAction.SKIPPED)
104
+
105
+ @property
106
+ def errored(self) -> int:
107
+ return sum(1 for o in self.outcomes if o.action == JiraSyncAction.ERRORED)
108
+
109
+
110
+ # ── Single-gap ────────────────────────────────────────────────────────────
111
+
112
+
113
+ def push_gap_to_jira(
114
+ gap: ControlGap,
115
+ client: JiraClient,
116
+ *,
117
+ force: bool = False,
118
+ ) -> JiraSyncOutcome:
119
+ """Create a Jira issue for a gap.
120
+
121
+ If ``gap.jira_issue_key`` is already populated, the function is a
122
+ no-op (SKIPPED) — unless ``force=True``, in which case a new issue
123
+ is created regardless. The previous key isn't deleted from Jira;
124
+ only the Evidentia linkage is overwritten.
125
+
126
+ The function mutates ``gap.jira_issue_key`` on success so callers
127
+ can persist the updated ``GapAnalysisReport`` back to the gap
128
+ store.
129
+ """
130
+ if gap.jira_issue_key and not force:
131
+ return JiraSyncOutcome(
132
+ gap_id=gap.id,
133
+ control_id=gap.control_id,
134
+ framework=gap.framework,
135
+ action=JiraSyncAction.SKIPPED,
136
+ issue_key=gap.jira_issue_key,
137
+ detail=(
138
+ f"Already linked to {gap.jira_issue_key}; pass force=True "
139
+ "to create a new issue anyway."
140
+ ),
141
+ )
142
+
143
+ try:
144
+ payload = gap_to_create_request(gap)
145
+ except JiraMappingError as e:
146
+ return JiraSyncOutcome(
147
+ gap_id=gap.id,
148
+ control_id=gap.control_id,
149
+ framework=gap.framework,
150
+ action=JiraSyncAction.ERRORED,
151
+ detail=f"Mapping error: {e}",
152
+ )
153
+
154
+ try:
155
+ # gap_to_create_request returns dict[str, object] because the
156
+ # nested values mix strings + lists + nested dicts. Narrow to the
157
+ # concrete shapes JiraClient.create_issue expects; the mapper's
158
+ # unit tests assert these slots hold their expected types.
159
+ labels_raw = payload["labels"]
160
+ extras_raw = payload["extra_fields"]
161
+ labels: list[str] = list(labels_raw) if isinstance(labels_raw, list) else []
162
+ extras: dict[str, object] = (
163
+ dict(extras_raw) if isinstance(extras_raw, dict) else {}
164
+ )
165
+ issue: JiraIssue = client.create_issue(
166
+ summary=str(payload["summary"]),
167
+ description=str(payload["description"]),
168
+ labels=labels,
169
+ extra_fields=extras,
170
+ )
171
+ except JiraApiError as e:
172
+ return JiraSyncOutcome(
173
+ gap_id=gap.id,
174
+ control_id=gap.control_id,
175
+ framework=gap.framework,
176
+ action=JiraSyncAction.ERRORED,
177
+ detail=f"Jira API error: {e}",
178
+ )
179
+
180
+ gap.jira_issue_key = issue.key
181
+ return JiraSyncOutcome(
182
+ gap_id=gap.id,
183
+ control_id=gap.control_id,
184
+ framework=gap.framework,
185
+ action=JiraSyncAction.CREATED,
186
+ issue_key=issue.key,
187
+ issue_url=issue.url,
188
+ detail=f"Created {issue.key}",
189
+ )
190
+
191
+
192
+ def sync_gap_from_jira(
193
+ gap: ControlGap,
194
+ client: JiraClient,
195
+ ) -> JiraSyncOutcome:
196
+ """Read ``gap.jira_issue_key``'s status + update the gap if it changed.
197
+
198
+ - No-op SKIPPED when ``gap.jira_issue_key`` is blank.
199
+ - UPDATED when the Jira status maps to a different GapStatus than
200
+ the gap currently holds.
201
+ - SKIPPED ("status unchanged") when the mapping matches.
202
+ - SKIPPED ("unknown Jira status") when Jira's status name isn't in
203
+ :data:`JIRA_STATUS_TO_GAP_STATUS`.
204
+ - ERRORED on HTTP / API failures.
205
+ """
206
+ if not gap.jira_issue_key:
207
+ return JiraSyncOutcome(
208
+ gap_id=gap.id,
209
+ control_id=gap.control_id,
210
+ framework=gap.framework,
211
+ action=JiraSyncAction.SKIPPED,
212
+ detail="Gap is not linked to a Jira issue yet.",
213
+ )
214
+
215
+ try:
216
+ issue = client.get_issue(gap.jira_issue_key)
217
+ except JiraApiError as e:
218
+ return JiraSyncOutcome(
219
+ gap_id=gap.id,
220
+ control_id=gap.control_id,
221
+ framework=gap.framework,
222
+ action=JiraSyncAction.ERRORED,
223
+ issue_key=gap.jira_issue_key,
224
+ detail=f"Jira API error: {e}",
225
+ )
226
+
227
+ mapped = jira_status_to_gap_status(issue.status_name)
228
+ if mapped is None:
229
+ return JiraSyncOutcome(
230
+ gap_id=gap.id,
231
+ control_id=gap.control_id,
232
+ framework=gap.framework,
233
+ action=JiraSyncAction.SKIPPED,
234
+ issue_key=issue.key,
235
+ issue_url=issue.url,
236
+ detail=(
237
+ f"Jira status {issue.status_name!r} isn't in the default "
238
+ "mapping; leaving gap.status unchanged. Add to "
239
+ "JIRA_STATUS_TO_GAP_STATUS to honor custom workflow names."
240
+ ),
241
+ )
242
+
243
+ current = _coerce_gap_status(gap.status)
244
+ if current == mapped:
245
+ return JiraSyncOutcome(
246
+ gap_id=gap.id,
247
+ control_id=gap.control_id,
248
+ framework=gap.framework,
249
+ action=JiraSyncAction.SKIPPED,
250
+ issue_key=issue.key,
251
+ issue_url=issue.url,
252
+ detail=f"Status unchanged ({mapped.value}).",
253
+ )
254
+
255
+ gap.status = mapped
256
+ if mapped == GapStatus.REMEDIATED and gap.remediated_at is None:
257
+ gap.remediated_at = datetime.now(UTC)
258
+
259
+ return JiraSyncOutcome(
260
+ gap_id=gap.id,
261
+ control_id=gap.control_id,
262
+ framework=gap.framework,
263
+ action=JiraSyncAction.UPDATED,
264
+ issue_key=issue.key,
265
+ issue_url=issue.url,
266
+ new_status=mapped,
267
+ detail=(
268
+ f"Updated gap status: {current.value if current else '?'} -> "
269
+ f"{mapped.value} (Jira: {issue.status_name})"
270
+ ),
271
+ )
272
+
273
+
274
+ def _coerce_gap_status(value: object) -> GapStatus | None:
275
+ """Accept enum or string forms. Returns None for unknown strings."""
276
+ if isinstance(value, GapStatus):
277
+ return value
278
+ if isinstance(value, str):
279
+ try:
280
+ return GapStatus(value)
281
+ except ValueError:
282
+ return None
283
+ return None
284
+
285
+
286
+ # ── Batch ────────────────────────────────────────────────────────────────
287
+
288
+
289
+ def push_open_gaps(
290
+ report: GapAnalysisReport,
291
+ client: JiraClient,
292
+ *,
293
+ severity_filter: set[str] | None = None,
294
+ max_issues: int | None = None,
295
+ ) -> JiraSyncResult:
296
+ """Push every OPEN gap in a report as a Jira issue.
297
+
298
+ Already-linked gaps are skipped. Use ``severity_filter`` to restrict
299
+ to e.g. ``{"critical", "high"}``; ``max_issues`` caps total creations
300
+ (safety rail for first-time setup against a big report).
301
+ """
302
+ result = JiraSyncResult()
303
+ created = 0
304
+
305
+ for gap in report.gaps:
306
+ current_status = _coerce_gap_status(gap.status)
307
+ if current_status not in (GapStatus.OPEN, GapStatus.IN_PROGRESS):
308
+ continue
309
+ if severity_filter is not None:
310
+ severity_str = (
311
+ gap.gap_severity.value
312
+ if hasattr(gap.gap_severity, "value")
313
+ else str(gap.gap_severity)
314
+ )
315
+ if severity_str not in severity_filter:
316
+ continue
317
+ if max_issues is not None and created >= max_issues:
318
+ result.outcomes.append(
319
+ JiraSyncOutcome(
320
+ gap_id=gap.id,
321
+ control_id=gap.control_id,
322
+ framework=gap.framework,
323
+ action=JiraSyncAction.SKIPPED,
324
+ detail=f"max_issues={max_issues} reached; skipping remaining gaps.",
325
+ )
326
+ )
327
+ continue
328
+
329
+ outcome = push_gap_to_jira(gap, client)
330
+ result.outcomes.append(outcome)
331
+ if outcome.action == JiraSyncAction.CREATED:
332
+ created += 1
333
+
334
+ return result
335
+
336
+
337
+ def sync_report(
338
+ report: GapAnalysisReport,
339
+ client: JiraClient,
340
+ ) -> JiraSyncResult:
341
+ """Sync every linked gap's status from Jira."""
342
+ result = JiraSyncResult()
343
+ for gap in report.gaps:
344
+ if not gap.jira_issue_key:
345
+ continue
346
+ result.outcomes.append(sync_gap_from_jira(gap, client))
347
+ return result
348
+
349
+
350
+ __all__ = [
351
+ "JiraSyncAction",
352
+ "JiraSyncOutcome",
353
+ "JiraSyncResult",
354
+ "push_gap_to_jira",
355
+ "push_open_gaps",
356
+ "sync_gap_from_jira",
357
+ "sync_report",
358
+ ]