imbi-plugin-sentry 2.46.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,30 @@
1
+ __pycache__
2
+ *.egg-info
3
+ *.pyc
4
+ *.env
5
+ .env
6
+ .env.test
7
+ build/
8
+ dist/
9
+ docs/site/
10
+ helm/imbi/charts/
11
+ runtime/state/
12
+ .venv/
13
+ .coverage
14
+ .mypy_cache/
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ coverage.xml
18
+ site/
19
+
20
+ # moon
21
+ .moon/cache
22
+ .moon/docker
23
+
24
+ # design-sync
25
+ .design-sync/sb-reference/
26
+ .design-sync/learnings/
27
+ .design-sync/.cache/
28
+ .design-sync/node_modules
29
+ .ds-sync/
30
+ ds-bundle/
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.5
2
+ Name: imbi-plugin-sentry
3
+ Version: 2.46.0
4
+ Summary: Sentry integration plugin for Imbi
5
+ Author-email: "Gavin M. Roy" <gavinr@aweber.com>
6
+ License-Expression: BSD-3-Clause
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Natural Language :: English
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Python: >=3.14
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: imbi-common==2.46.0
16
+ Requires-Dist: pydantic>=2
17
+ Description-Content-Type: text/markdown
18
+
19
+ # imbi-plugin-sentry
20
+
21
+ Sentry integration plugin for Imbi (Plugin Architecture v3). See
22
+ [the plugin documentation](https://aweber-imbi.github.io/imbi/plugins/sentry/).
@@ -0,0 +1,4 @@
1
+ # imbi-plugin-sentry
2
+
3
+ Sentry integration plugin for Imbi (Plugin Architecture v3). See
4
+ [the plugin documentation](https://aweber-imbi.github.io/imbi/plugins/sentry/).
@@ -0,0 +1,22 @@
1
+ # yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json
2
+ layer: 'library'
3
+ stack: 'backend'
4
+ language: 'python'
5
+ tags:
6
+ - 'python'
7
+
8
+ project:
9
+ name: 'imbi-plugin-sentry'
10
+ description: 'Sentry plugin for Imbi'
11
+
12
+ dependsOn:
13
+ - 'common'
14
+
15
+ # Inherits lint / format / typecheck / test from .moon/tasks/python.yml. The
16
+ # shared test task points --cov-config at this member's pyproject, where
17
+ # [tool.coverage] scopes source + threshold to the member.
18
+ tasks:
19
+ test:
20
+ args:
21
+ - '--cov'
22
+ - '--cov-report=term-missing'
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "imbi-plugin-sentry"
3
+ version = "2.46.0"
4
+ description = "Sentry integration plugin for Imbi"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ license = "BSD-3-Clause"
8
+ authors = [
9
+ { name = "Gavin M. Roy", email = "gavinr@aweber.com" },
10
+ ]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Natural Language :: English",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.14",
18
+ ]
19
+ dependencies = [
20
+ "httpx>=0.27",
21
+ "imbi-common==2.46.0",
22
+ "pydantic>=2",
23
+ ]
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ # Coverage for the per-member `moon run sentry:test` run. Scoped to this
30
+ # member so the report/threshold reflect only imbi.plugins.sentry; the aggregate
31
+ # `root:coverage` run uses the root pyproject config instead.
32
+ [tool.coverage.run]
33
+ branch = true
34
+ source_pkgs = ["imbi.plugins.sentry"]
35
+
36
+ [tool.coverage.report]
37
+ exclude_also = ["typing.TYPE_CHECKING"]
38
+ fail_under = 85
39
+ show_missing = true
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/imbi"]
43
+ include = [
44
+ "src/imbi/plugins/sentry/py.typed",
45
+ ]
@@ -0,0 +1,9 @@
1
+ from importlib import metadata
2
+
3
+ from imbi.plugins.sentry.plugin import SentryPlugin
4
+
5
+ #: Discovered by the imbi-common registry's convention scan.
6
+ PLUGIN = SentryPlugin
7
+
8
+ version = metadata.version('imbi-plugin-sentry')
9
+ __all__ = ['PLUGIN', 'SentryPlugin', 'version']
@@ -0,0 +1,90 @@
1
+ """Webhook action: sync the project's Sentry issue-count facts."""
2
+
3
+ import logging
4
+ import typing
5
+ import urllib.parse
6
+
7
+ import pydantic
8
+ import pydantic_settings
9
+
10
+ from imbi.common.api import client as api_client
11
+ from imbi.common.plugins import base as plugin_base
12
+ from imbi.plugins.sentry import client
13
+
14
+ LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ class _ImbiSettings(pydantic_settings.BaseSettings):
18
+ """Imbi API connection settings, the same environment variables the
19
+ gateway's own actions read (see the SonarQube plugin)."""
20
+
21
+ model_config = {'env_prefix': 'IMBI_GATEWAY_'}
22
+
23
+ imbi_url: pydantic.HttpUrl = pydantic.Field(
24
+ default=pydantic.HttpUrl('http://localhost:8000'),
25
+ validation_alias='IMBI_INTERNAL_API_URL',
26
+ )
27
+ # imbi-api mounts its routers under the path of its public URL.
28
+ api_public_url: str = pydantic.Field(
29
+ default='', validation_alias='IMBI_API_URL'
30
+ )
31
+ api_token: str
32
+
33
+ @property
34
+ def api_base_url(self) -> str:
35
+ prefix = urllib.parse.urlparse(self.api_public_url).path.rstrip('/')
36
+ return str(self.imbi_url).rstrip('/') + prefix
37
+
38
+
39
+ class SyncIssueCountsConfig(pydantic.BaseModel):
40
+ """No settings: the facts and searches are Integration options."""
41
+
42
+
43
+ async def sync_issue_counts(
44
+ *,
45
+ ctx: plugin_base.PluginContext,
46
+ credentials: dict[str, str],
47
+ external_identifier: str,
48
+ action_config: SyncIssueCountsConfig,
49
+ event: object,
50
+ ) -> None:
51
+ """Re-count the project's Sentry issues and patch the facts.
52
+
53
+ ``external_identifier`` is the Sentry project slug the gateway
54
+ matched against ``EXISTS_IN.identifier``. The count is read from
55
+ Sentry, not derived from the event, so a missed delivery cannot make
56
+ the fact drift.
57
+ """
58
+ del action_config, event
59
+ try:
60
+ s = client.settings(ctx, credentials)
61
+ project = await client.get_project(s, external_identifier)
62
+ if project is None:
63
+ LOGGER.warning(
64
+ 'Sentry project %r not found; skipping', external_identifier
65
+ )
66
+ return
67
+ counts = await client.count_all(ctx, s, project)
68
+ except client.SentryClientError:
69
+ LOGGER.exception(
70
+ 'Failed to count Sentry issues for %r', external_identifier
71
+ )
72
+ return
73
+ await _patch_imbi_project(
74
+ ctx.org_slug,
75
+ ctx.project_id,
76
+ [
77
+ {'op': 'add', 'path': f'/{fact}', 'value': count}
78
+ for fact, count in counts.items()
79
+ ],
80
+ )
81
+
82
+
83
+ async def _patch_imbi_project(
84
+ org_slug: str, project_id: str, patch: list[dict[str, typing.Any]]
85
+ ) -> None:
86
+ settings = _ImbiSettings() # type: ignore[call-arg]
87
+ async with api_client.Imbi(
88
+ base_url=settings.api_base_url, token=settings.api_token, timeout=30.0
89
+ ) as imbi:
90
+ await imbi.patch_project(org_slug, project_id, patch)
@@ -0,0 +1,298 @@
1
+ """Sentry REST API client, plus the settings every capability shares."""
2
+
3
+ import asyncio
4
+ import importlib.metadata
5
+ import re
6
+ import typing
7
+ import urllib.parse
8
+
9
+ import httpx
10
+
11
+ from imbi.common.plugins.base import PluginContext, ServiceWriteback
12
+
13
+ _VERSION = importlib.metadata.version('imbi-plugin-sentry')
14
+
15
+ #: Project link key the issues URL is written under.
16
+ LINK_KEY = 'sentry'
17
+
18
+ #: Fact name -> Sentry issue search, when the operator sets none.
19
+ DEFAULT_ISSUE_COUNT_FACTS = {'sentry_open_issues': 'is:unresolved'}
20
+
21
+ #: Issue search window, when the operator sets none. Sentry's issue
22
+ #: stream defaults to the same window, so the linked page shows the
23
+ #: issues that were counted.
24
+ DEFAULT_ISSUE_PERIOD = '14d'
25
+
26
+
27
+ class SentryClientError(Exception):
28
+ """Raised on non-2xx responses or transport errors."""
29
+
30
+
31
+ _SLUG_RE = re.compile(r'[A-Za-z0-9_-]+')
32
+
33
+
34
+ class Settings(typing.NamedTuple):
35
+ """What every Sentry call needs, resolved from the Integration."""
36
+
37
+ base_url: str
38
+ organization: str
39
+ auth_token: str
40
+
41
+
42
+ def settings(ctx: PluginContext, credentials: dict[str, str]) -> Settings:
43
+ """Resolve the Integration settings, or raise :class:`SentryClientError`.
44
+
45
+ The message is the one the operator sees, so it names what to set.
46
+ """
47
+ base_url = str(ctx.integration_options.get('service_url') or '').strip()
48
+ organization = str(
49
+ ctx.integration_options.get('organization') or ''
50
+ ).strip()
51
+ auth_token = credentials.get('auth_token', '')
52
+ if not organization:
53
+ raise SentryClientError('The Integration has no organization set.')
54
+ if not auth_token:
55
+ raise SentryClientError('No auth_token credential configured.')
56
+ return Settings(
57
+ (base_url or 'https://sentry.io').rstrip('/'), organization, auth_token
58
+ )
59
+
60
+
61
+ def _mapping(ctx: PluginContext, name: str) -> dict[str, str]:
62
+ """Return the ``mapping`` option ``name`` as ``dict[str, str]``."""
63
+ raw: object = ctx.integration_options.get(name)
64
+ if not isinstance(raw, dict):
65
+ return {}
66
+ items = typing.cast('dict[object, object]', raw).items()
67
+ return {str(k): str(v) for k, v in items}
68
+
69
+
70
+ def issue_count_facts(ctx: PluginContext) -> dict[str, str]:
71
+ """Return the configured ``fact name -> Sentry issue search`` map."""
72
+ return _mapping(ctx, 'issue_count_facts') or dict(
73
+ DEFAULT_ISSUE_COUNT_FACTS
74
+ )
75
+
76
+
77
+ def issue_period(ctx: PluginContext) -> str:
78
+ raw = str(ctx.integration_options.get('issue_period') or '').strip()
79
+ return raw or DEFAULT_ISSUE_PERIOD
80
+
81
+
82
+ def sentry_team(ctx: PluginContext) -> str | None:
83
+ """Return the Sentry team that owns the project's Sentry project.
84
+
85
+ The ``team_mapping`` option wins; an unmapped team uses its own slug.
86
+ """
87
+ if ctx.team_slug is None:
88
+ return None
89
+ return _mapping(ctx, 'team_mapping').get(ctx.team_slug) or ctx.team_slug
90
+
91
+
92
+ def writeback(
93
+ ctx: PluginContext, s: Settings, project: dict[str, typing.Any]
94
+ ) -> ServiceWriteback:
95
+ """Build the ``EXISTS_IN`` edge + issues link for a Sentry project."""
96
+ slug = str(project['slug'])
97
+ query = urllib.parse.urlencode(
98
+ {'project': project['id'], 'statsPeriod': issue_period(ctx)}
99
+ )
100
+ return ServiceWriteback(
101
+ identifier=slug,
102
+ canonical_url=canonical_url(s, slug),
103
+ dashboard_links={
104
+ LINK_KEY: (
105
+ f'{s.base_url}/organizations/{s.organization}/issues/?{query}'
106
+ )
107
+ },
108
+ )
109
+
110
+
111
+ def _q(segment: str) -> str:
112
+ """Return a Sentry slug for a URL path, or raise.
113
+
114
+ Sentry slugs hold only letters, digits, ``-`` and ``_``. Anything
115
+ else (``..``, ``/``, encoded forms of either) could point a call at a
116
+ different API path, because httpx normalizes the path.
117
+ """
118
+ if not _SLUG_RE.fullmatch(segment):
119
+ raise SentryClientError(f'{segment!r} is not a valid Sentry slug')
120
+ return segment
121
+
122
+
123
+ def canonical_url(s: Settings, slug: str) -> str:
124
+ return f'{s.base_url}/api/0/projects/{_q(s.organization)}/{_q(slug)}/'
125
+
126
+
127
+ async def _request(
128
+ s: Settings,
129
+ method: str,
130
+ path: str,
131
+ *,
132
+ params: dict[str, str] | None = None,
133
+ json: dict[str, typing.Any] | None = None,
134
+ ) -> httpx.Response:
135
+ headers = {
136
+ 'Authorization': f'Bearer {s.auth_token}',
137
+ 'Accept': 'application/json',
138
+ 'User-Agent': f'imbi-plugin-sentry/{_VERSION}',
139
+ }
140
+ try:
141
+ # Within the host's default 10 second plugin deadline.
142
+ async with httpx.AsyncClient(timeout=10.0) as http_client:
143
+ return await http_client.request(
144
+ method,
145
+ f'{s.base_url}/api/0{path}',
146
+ params=params,
147
+ json=json,
148
+ headers=headers,
149
+ )
150
+ except httpx.RequestError as exc:
151
+ raise SentryClientError(f'Sentry request failed: {exc}') from exc
152
+
153
+
154
+ def _raise_for_error(response: httpx.Response) -> None:
155
+ if response.is_error:
156
+ raise SentryClientError(
157
+ f'Sentry returned status {response.status_code}: '
158
+ f'{response.text[:200]}'
159
+ )
160
+
161
+
162
+ def _json(response: httpx.Response) -> dict[str, typing.Any]:
163
+ _raise_for_error(response)
164
+ try:
165
+ payload: object = response.json()
166
+ except ValueError as exc:
167
+ raise SentryClientError('Sentry returned non-JSON response') from exc
168
+ if not isinstance(payload, dict):
169
+ raise SentryClientError('Sentry returned an unexpected payload')
170
+ return typing.cast('dict[str, typing.Any]', payload)
171
+
172
+
173
+ async def get_project(s: Settings, slug: str) -> dict[str, typing.Any] | None:
174
+ """Return the Sentry project ``slug``, or ``None`` when it is absent."""
175
+ response = await _request(
176
+ s, 'GET', f'/projects/{_q(s.organization)}/{_q(slug)}/'
177
+ )
178
+ if response.status_code == 404:
179
+ return None
180
+ return _json(response)
181
+
182
+
183
+ async def create_project(
184
+ s: Settings, team: str, slug: str, name: str
185
+ ) -> dict[str, typing.Any]:
186
+ """Create a Sentry project owned by ``team``.
187
+
188
+ Sentry answers ``404`` when the team does not exist.
189
+ """
190
+ response = await _request(
191
+ s,
192
+ 'POST',
193
+ f'/teams/{_q(s.organization)}/{_q(team)}/projects/',
194
+ json={'name': name, 'slug': slug},
195
+ )
196
+ if response.status_code == 404:
197
+ raise SentryClientError(
198
+ f'Sentry team {team!r} does not exist; map the Imbi team to a '
199
+ 'Sentry team in the team_mapping option.'
200
+ )
201
+ return _json(response)
202
+
203
+
204
+ def team_slugs(project: dict[str, typing.Any]) -> list[str]:
205
+ """Return the slugs of the teams that own a Sentry project."""
206
+ teams: object = project.get('teams') or []
207
+ if not isinstance(teams, list):
208
+ return []
209
+ return [
210
+ str(typing.cast('dict[str, typing.Any]', t).get('slug'))
211
+ for t in typing.cast('list[object]', teams)
212
+ if isinstance(t, dict)
213
+ ]
214
+
215
+
216
+ def may_link(project: dict[str, typing.Any], team: str | None) -> bool:
217
+ """Return whether Imbi may link an existing Sentry project.
218
+
219
+ Only when no team owns it, or ``team`` already does: linking a
220
+ project owned only by other teams would hand it to this project.
221
+ """
222
+ current = team_slugs(project)
223
+ return not current or team in current
224
+
225
+
226
+ async def set_team(
227
+ s: Settings, project: dict[str, typing.Any], team: str
228
+ ) -> list[str]:
229
+ """Make ``team`` the only team that owns ``project``.
230
+
231
+ Removes other teams only when ``team`` already owns the project, so
232
+ someone with access in Sentry has granted it; a project with no team
233
+ gets ``team`` added. Otherwise raises, because replacing the owners
234
+ of a project that ``team`` has no claim to would take it from them.
235
+ Returns the removed team slugs.
236
+ """
237
+ slug = str(project['slug'])
238
+ path = f'/projects/{_q(s.organization)}/{_q(slug)}/teams'
239
+ current = team_slugs(project)
240
+ if current and team not in current:
241
+ raise SentryClientError(
242
+ f'Sentry team {team!r} does not own this project; add it to the '
243
+ 'project in Sentry first.'
244
+ )
245
+ if not current:
246
+ response = await _request(s, 'POST', f'{path}/{_q(team)}/')
247
+ if response.status_code == 404:
248
+ raise SentryClientError(
249
+ f'Sentry team {team!r} does not exist; map the Imbi team to '
250
+ 'a Sentry team in the team_mapping option.'
251
+ )
252
+ _raise_for_error(response)
253
+ removed = [t for t in current if t != team]
254
+ for other in removed:
255
+ _raise_for_error(await _request(s, 'DELETE', f'{path}/{_q(other)}/'))
256
+ return removed
257
+
258
+
259
+ async def count_issues(
260
+ s: Settings, project_id: str, query: str, period: str
261
+ ) -> int:
262
+ """Count the project's issues that match ``query`` in ``period``.
263
+
264
+ Reads the ``X-Hits`` header of the organization issues endpoint.
265
+ """
266
+ # ponytail: X-Hits is approximate and caps at 1000; paginate the
267
+ # issue list if a fact needs an exact count past that.
268
+ response = await _request(
269
+ s,
270
+ 'GET',
271
+ f'/organizations/{_q(s.organization)}/issues/',
272
+ params={
273
+ 'project': project_id,
274
+ 'query': query,
275
+ 'statsPeriod': period,
276
+ 'limit': '1',
277
+ },
278
+ )
279
+ _raise_for_error(response)
280
+ hits = response.headers.get('X-Hits')
281
+ if hits is None or not hits.isdigit():
282
+ raise SentryClientError('Sentry returned no X-Hits issue count')
283
+ return int(hits)
284
+
285
+
286
+ async def count_all(
287
+ ctx: PluginContext, s: Settings, project: dict[str, typing.Any]
288
+ ) -> dict[str, int]:
289
+ """Return ``{fact name: issue count}`` for every configured fact."""
290
+ period = issue_period(ctx)
291
+ facts = issue_count_facts(ctx)
292
+ counts = await asyncio.gather(
293
+ *(
294
+ count_issues(s, str(project['id']), query, period)
295
+ for query in facts.values()
296
+ )
297
+ )
298
+ return dict(zip(facts, counts, strict=True))