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.
- evidentia_integrations-0.6.0/.gitignore +95 -0
- evidentia_integrations-0.6.0/PKG-INFO +49 -0
- evidentia_integrations-0.6.0/README.md +19 -0
- evidentia_integrations-0.6.0/pyproject.toml +43 -0
- evidentia_integrations-0.6.0/src/evidentia_integrations/__init__.py +9 -0
- evidentia_integrations-0.6.0/src/evidentia_integrations/jira/__init__.py +66 -0
- evidentia_integrations-0.6.0/src/evidentia_integrations/jira/client.py +288 -0
- evidentia_integrations-0.6.0/src/evidentia_integrations/jira/config.py +122 -0
- evidentia_integrations-0.6.0/src/evidentia_integrations/jira/mapper.py +170 -0
- evidentia_integrations-0.6.0/src/evidentia_integrations/jira/sync.py +358 -0
- evidentia_integrations-0.6.0/src/evidentia_integrations/py.typed +0 -0
|
@@ -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
|
+
]
|
|
File without changes
|