imbi-plugin-sentry 2.46.0__py3-none-any.whl

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,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))
@@ -0,0 +1,383 @@
1
+ """Sentry project doctor.
2
+
3
+ Checks that the project's ``EXISTS_IN`` edge names a live Sentry project
4
+ and that the edge and the ``sentry`` issues link match it, offering to
5
+ create or link the project. Then compares each configured issue-count
6
+ fact against Sentry and offers a sweepable sync.
7
+
8
+ Diagnosis is best-effort: missing configuration or an unreachable Sentry
9
+ degrades to ``warn`` findings, so the Doctor panel never hard-fails.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import typing
15
+
16
+ from imbi.common.plugins.base import (
17
+ AnalysisCapability,
18
+ AnalysisResultItem,
19
+ AnalysisResultStatus,
20
+ PluginContext,
21
+ RemediationOffer,
22
+ RemediationResult,
23
+ ServiceConnection,
24
+ )
25
+ from imbi.plugins.sentry import client
26
+ from imbi.plugins.sentry.lifecycle import find_or_create
27
+
28
+ #: Search Sentry for the project, create it when absent, write the edge.
29
+ _CREATE = 'create-project'
30
+ #: Rewrite the edge + link for a project analyze() found. Never creates.
31
+ _LINK = 'link-project'
32
+ #: Write the issue-count facts.
33
+ _SYNC_FACTS = 'sync-issue-counts'
34
+ #: Make the expected team the only owner of the Sentry project.
35
+ _SET_TEAM = 'set-team'
36
+
37
+ _CREATE_OFFER = RemediationOffer(
38
+ id=_CREATE,
39
+ label='Create / link the Sentry project',
40
+ confirm=(
41
+ 'This creates the Sentry project if it does not exist, then links '
42
+ 'it to this project.'
43
+ ),
44
+ destructive=True,
45
+ )
46
+ _SET_TEAM_OFFER = RemediationOffer(
47
+ id=_SET_TEAM,
48
+ label='Set the Sentry team',
49
+ confirm=(
50
+ 'This makes the expected team the only owner of the Sentry project. '
51
+ 'Every other team loses its access to the project.'
52
+ ),
53
+ destructive=True,
54
+ )
55
+ _LINK_OFFER = RemediationOffer(id=_LINK, label='Repair the Sentry link')
56
+ _SYNC_OFFER = RemediationOffer(
57
+ id=_SYNC_FACTS, label='Sync issue counts from Sentry', sweepable=True
58
+ )
59
+
60
+
61
+ def _item(
62
+ slug: str,
63
+ title: str,
64
+ status: AnalysisResultStatus,
65
+ description: str,
66
+ remediation: RemediationOffer | None = None,
67
+ ) -> AnalysisResultItem:
68
+ return AnalysisResultItem(
69
+ slug=slug,
70
+ title=title,
71
+ status=status,
72
+ description=description,
73
+ remediation=remediation,
74
+ )
75
+
76
+
77
+ def _connection(ctx: PluginContext) -> ServiceConnection | None:
78
+ return next(
79
+ (
80
+ c
81
+ for c in ctx.service_connections
82
+ if c.integration_slug == ctx.integration_slug
83
+ ),
84
+ None,
85
+ )
86
+
87
+
88
+ def _slug(ctx: PluginContext) -> str:
89
+ """The edge's identifier wins over the project-slug convention."""
90
+ connection = _connection(ctx)
91
+ return connection.identifier if connection else ctx.project_slug
92
+
93
+
94
+ def _count_matches(value: object, count: int) -> bool:
95
+ """Return whether a stored fact equals a Sentry count.
96
+
97
+ A bool is an int in Python, so ``True == 1``; reject it explicitly.
98
+ """
99
+ return type(value) is int and value == count
100
+
101
+
102
+ def _may_link(ctx: PluginContext, project: dict[str, typing.Any]) -> bool:
103
+ """Return whether the Doctor may link ``project`` to this project.
104
+
105
+ An existing edge is kept: someone already chose it. A new link follows
106
+ the rule of :func:`find_or_create`.
107
+ """
108
+ return _connection(ctx) is not None or client.may_link(
109
+ project, client.sentry_team(ctx)
110
+ )
111
+
112
+
113
+ def _owns_sentry_project(ctx: PluginContext) -> bool:
114
+ """Return whether the project may change its Sentry project's teams.
115
+
116
+ Only for the Sentry project named after this project, through an edge
117
+ the plugin wrote itself (or no edge yet). An identifier set by hand
118
+ could name another team's Sentry project, and replacing its teams
119
+ would take that project away from them.
120
+ """
121
+ connection = _connection(ctx)
122
+ if connection is not None and (
123
+ connection.verified_identifier != connection.identifier
124
+ ):
125
+ return False
126
+ return _slug(ctx) == ctx.project_slug
127
+
128
+
129
+ class SentryDoctor(AnalysisCapability):
130
+ """Validate and repair the project's Sentry project binding."""
131
+
132
+ async def analyze(
133
+ self, ctx: PluginContext, credentials: dict[str, str]
134
+ ) -> list[AnalysisResultItem]:
135
+ if not ctx.integration_slug:
136
+ return [
137
+ _item(
138
+ 'exists-in',
139
+ 'Sentry project',
140
+ 'warn',
141
+ 'This capability is not bound to an Integration.',
142
+ )
143
+ ]
144
+ slug = _slug(ctx)
145
+ try:
146
+ s = client.settings(ctx, credentials)
147
+ project = await client.get_project(s, slug)
148
+ except client.SentryClientError as exc:
149
+ return [
150
+ _item(
151
+ 'exists-in',
152
+ 'Sentry project',
153
+ 'warn',
154
+ f'Cannot verify Sentry project {slug!r}: {exc}',
155
+ )
156
+ ]
157
+ if project is None:
158
+ return [
159
+ _item(
160
+ 'exists-in',
161
+ 'Sentry project',
162
+ 'fail',
163
+ f'Sentry project {slug!r} does not exist. Use the Fix '
164
+ 'action to create and link it.',
165
+ _CREATE_OFFER,
166
+ )
167
+ ]
168
+ return [
169
+ self._check_link(ctx, s, project),
170
+ self._check_team(ctx, project),
171
+ *await self._check_facts(ctx, s, project),
172
+ ]
173
+
174
+ def _check_link(
175
+ self,
176
+ ctx: PluginContext,
177
+ s: client.Settings,
178
+ project: dict[str, typing.Any],
179
+ ) -> AnalysisResultItem:
180
+ expected = client.writeback(ctx, s, project)
181
+ connection = _connection(ctx)
182
+ if (
183
+ connection is not None
184
+ and connection.identifier == expected.identifier
185
+ and connection.canonical_url == expected.canonical_url
186
+ and ctx.project_links.get(client.LINK_KEY)
187
+ == expected.dashboard_links[client.LINK_KEY]
188
+ ):
189
+ return _item(
190
+ 'exists-in',
191
+ 'Sentry project',
192
+ 'pass',
193
+ f'Linked to Sentry project {expected.identifier!r}.',
194
+ )
195
+ if not _may_link(ctx, project):
196
+ return _item(
197
+ 'exists-in',
198
+ 'Sentry project',
199
+ 'fail',
200
+ f'Sentry project {expected.identifier!r} is owned only by '
201
+ f'other teams ({", ".join(client.team_slugs(project))}); '
202
+ 'Imbi cannot link it. Add the mapped team to it in Sentry, '
203
+ 'then re-run the doctor.',
204
+ )
205
+ return _item(
206
+ 'exists-in',
207
+ 'Sentry project',
208
+ 'warn' if connection is None else 'fail',
209
+ f'Sentry project {expected.identifier!r} exists, but the '
210
+ 'project edge or issues link does not match it. Use the Fix '
211
+ 'action to link it.',
212
+ _LINK_OFFER,
213
+ )
214
+
215
+ def _check_team(
216
+ self, ctx: PluginContext, project: dict[str, typing.Any]
217
+ ) -> AnalysisResultItem:
218
+ expected = client.sentry_team(ctx)
219
+ teams = client.team_slugs(project)
220
+ if expected is None:
221
+ return _item(
222
+ 'sentry-team',
223
+ 'Sentry team',
224
+ 'warn',
225
+ 'The project has no owning team to map to a Sentry team.',
226
+ )
227
+ if teams == [expected]:
228
+ return _item(
229
+ 'sentry-team',
230
+ 'Sentry team',
231
+ 'pass',
232
+ f'Sentry team {expected!r} owns the project.',
233
+ )
234
+ if not _owns_sentry_project(ctx) or not client.may_link(
235
+ project, expected
236
+ ):
237
+ return _item(
238
+ 'sentry-team',
239
+ 'Sentry team',
240
+ 'fail',
241
+ f'The Sentry project is owned by {teams or "no team"}; '
242
+ f'expected only {expected!r}. Imbi cannot claim this Sentry '
243
+ f'project; add {expected!r} to it in Sentry, then re-run the '
244
+ 'doctor.',
245
+ )
246
+ return _item(
247
+ 'sentry-team',
248
+ 'Sentry team',
249
+ 'fail',
250
+ f'The Sentry project is owned by {teams or "no team"}; expected '
251
+ f'only {expected!r}. Use the Fix action to set the team.',
252
+ _SET_TEAM_OFFER,
253
+ )
254
+
255
+ async def _check_facts(
256
+ self,
257
+ ctx: PluginContext,
258
+ s: client.Settings,
259
+ project: dict[str, typing.Any],
260
+ ) -> list[AnalysisResultItem]:
261
+ try:
262
+ counts = await client.count_all(ctx, s, project)
263
+ except client.SentryClientError as exc:
264
+ return [
265
+ _item(
266
+ 'issue-counts',
267
+ 'Sentry issue counts',
268
+ 'warn',
269
+ f'Cannot count Sentry issues: {exc}',
270
+ )
271
+ ]
272
+ return [
273
+ _item(
274
+ f'issue-count:{fact}',
275
+ f'Fact {fact}',
276
+ 'pass',
277
+ f'{fact} matches Sentry ({count}).',
278
+ )
279
+ if _count_matches(ctx.project_facts.get(fact), count)
280
+ else _item(
281
+ f'issue-count:{fact}',
282
+ f'Fact {fact}',
283
+ 'warn',
284
+ f'{fact} is {ctx.project_facts.get(fact)!r}; Sentry reports '
285
+ f'{count}.',
286
+ _SYNC_OFFER,
287
+ )
288
+ for fact, count in counts.items()
289
+ ]
290
+
291
+ async def remediate(
292
+ self,
293
+ ctx: PluginContext,
294
+ credentials: dict[str, str],
295
+ remediation_id: str,
296
+ ) -> RemediationResult:
297
+ if remediation_id not in (_CREATE, _LINK, _SYNC_FACTS, _SET_TEAM):
298
+ return await super().remediate(ctx, credentials, remediation_id)
299
+ slug = _slug(ctx)
300
+ try:
301
+ s = client.settings(ctx, credentials)
302
+ project: dict[str, typing.Any] | None
303
+ if remediation_id == _CREATE:
304
+ project, created = await find_or_create(ctx, s, slug)
305
+ else:
306
+ project, created = await client.get_project(s, slug), False
307
+ if project is None:
308
+ return RemediationResult(
309
+ status='failed',
310
+ message=(
311
+ f'Sentry project {slug!r} no longer exists; '
312
+ 're-run the doctor to create it.'
313
+ ),
314
+ )
315
+ if remediation_id == _LINK and not _may_link(ctx, project):
316
+ return RemediationResult(
317
+ status='failed',
318
+ message=(
319
+ f'Sentry project {slug!r} is owned only by other '
320
+ 'teams; refusing to link it.'
321
+ ),
322
+ )
323
+ if remediation_id == _SYNC_FACTS:
324
+ return await self._sync_facts(ctx, s, project)
325
+ if remediation_id == _SET_TEAM:
326
+ return await self._set_team(ctx, s, project)
327
+ except client.SentryClientError as exc:
328
+ return RemediationResult(status='failed', message=str(exc))
329
+ ctx.service_writeback = client.writeback(ctx, s, project)
330
+ verb = 'Created and linked' if created else 'Linked'
331
+ return RemediationResult(
332
+ status='fixed', message=f'{verb} Sentry project {slug!r}.'
333
+ )
334
+
335
+ async def _sync_facts(
336
+ self,
337
+ ctx: PluginContext,
338
+ s: client.Settings,
339
+ project: dict[str, typing.Any],
340
+ ) -> RemediationResult:
341
+ counts = await client.count_all(ctx, s, project)
342
+ if all(
343
+ _count_matches(ctx.project_facts.get(f), c)
344
+ for f, c in counts.items()
345
+ ):
346
+ return RemediationResult(
347
+ status='noop', message='Issue counts already match Sentry.'
348
+ )
349
+ ctx.fact_writeback = dict[str, typing.Any](counts)
350
+ return RemediationResult(
351
+ status='fixed',
352
+ message=', '.join(f'{f} = {c}' for f, c in counts.items()),
353
+ )
354
+
355
+ async def _set_team(
356
+ self,
357
+ ctx: PluginContext,
358
+ s: client.Settings,
359
+ project: dict[str, typing.Any],
360
+ ) -> RemediationResult:
361
+ if not _owns_sentry_project(ctx):
362
+ return RemediationResult(
363
+ status='failed',
364
+ message=(
365
+ 'Imbi did not link this Sentry project itself; change '
366
+ 'its teams in Sentry.'
367
+ ),
368
+ )
369
+ team = client.sentry_team(ctx)
370
+ if team is None:
371
+ return RemediationResult(
372
+ status='failed',
373
+ message='The project has no owning team to map.',
374
+ )
375
+ if client.team_slugs(project) == [team]:
376
+ return RemediationResult(
377
+ status='noop', message=f'Sentry team {team!r} already set.'
378
+ )
379
+ removed = await client.set_team(s, project, team)
380
+ suffix = f'; removed {", ".join(removed)}' if removed else ''
381
+ return RemediationResult(
382
+ status='fixed', message=f'Set Sentry team {team!r}{suffix}.'
383
+ )
@@ -0,0 +1,74 @@
1
+ """Sentry lifecycle capability: create the Sentry project with the Imbi one.
2
+
3
+ The Sentry project slug is the Imbi project slug and is written to the
4
+ ``EXISTS_IN`` edge, so the gateway routes Sentry webhooks to the
5
+ project. There is no delete hook: deleting the Imbi project drops its
6
+ edge and links, and the Sentry project keeps its issue history.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typing
12
+
13
+ from imbi.common.plugins.base import (
14
+ LifecycleCapability,
15
+ LifecycleResult,
16
+ PluginContext,
17
+ )
18
+ from imbi.plugins.sentry import client
19
+
20
+
21
+ async def find_or_create(
22
+ ctx: PluginContext, s: client.Settings, slug: str
23
+ ) -> tuple[dict[str, typing.Any], bool]:
24
+ """Return the Sentry project ``slug`` and whether it was created.
25
+
26
+ Raises :class:`client.SentryClientError` when it is absent and cannot
27
+ be created.
28
+ """
29
+ project = await client.get_project(s, slug)
30
+ team = client.sentry_team(ctx)
31
+ if project is not None:
32
+ if not client.may_link(project, team):
33
+ raise client.SentryClientError(
34
+ f'Sentry project {slug!r} exists and is owned only by other '
35
+ f'teams ({", ".join(client.team_slugs(project))}); refusing '
36
+ 'to link it.'
37
+ )
38
+ return project, False
39
+ if team is None:
40
+ raise client.SentryClientError(
41
+ 'The project has no owning team to map to a Sentry team.'
42
+ )
43
+ project = await client.create_project(
44
+ s, team, slug, ctx.project_name or slug
45
+ )
46
+ return project, True
47
+
48
+
49
+ class SentryLifecycle(LifecycleCapability):
50
+ """Create (or link) the project's Sentry project."""
51
+
52
+ async def on_project_archived(
53
+ self, ctx: PluginContext, credentials: dict[str, str]
54
+ ) -> LifecycleResult:
55
+ del ctx, credentials
56
+ return LifecycleResult(
57
+ status='skipped', message='Sentry has no archive concept'
58
+ )
59
+
60
+ async def on_project_created(
61
+ self, ctx: PluginContext, credentials: dict[str, str]
62
+ ) -> LifecycleResult:
63
+ try:
64
+ s = client.settings(ctx, credentials)
65
+ project, created = await find_or_create(ctx, s, ctx.project_slug)
66
+ except client.SentryClientError as exc:
67
+ return LifecycleResult(status='failed', message=str(exc))
68
+ ctx.service_writeback = client.writeback(ctx, s, project)
69
+ verb = 'Created' if created else 'Linked existing'
70
+ return LifecycleResult(
71
+ status='ok',
72
+ message=f'{verb} Sentry project {project["slug"]!r}',
73
+ artifacts={'project_id': str(project['id'])},
74
+ )
@@ -0,0 +1,148 @@
1
+ """Sentry plugin (Plugin Architecture v3)."""
2
+
3
+ from imbi.common import plugins
4
+ from imbi.common.plugins.base import WebhookSignature
5
+ from imbi.plugins.sentry import client
6
+ from imbi.plugins.sentry.doctor import SentryDoctor
7
+ from imbi.plugins.sentry.lifecycle import SentryLifecycle
8
+
9
+
10
+ class SentryWebhookActions(plugins.WebhookActionsCapability):
11
+ """Sentry integration-platform webhooks, routed by project slug."""
12
+
13
+ @classmethod
14
+ def actions(cls) -> list[plugins.ActionDescriptor]:
15
+ return [
16
+ plugins.ActionDescriptor.model_validate(
17
+ {
18
+ 'name': 'sync_issue_counts',
19
+ 'label': 'Sync Sentry issue counts',
20
+ 'description': (
21
+ 'Counts the Sentry issues that match each search in '
22
+ 'the issue_count_facts option and patches the '
23
+ 'matching project facts.'
24
+ ),
25
+ 'callable': (
26
+ 'imbi.plugins.sentry.actions:sync_issue_counts'
27
+ ),
28
+ 'config_model': (
29
+ 'imbi.plugins.sentry.actions:SyncIssueCountsConfig'
30
+ ),
31
+ }
32
+ )
33
+ ]
34
+
35
+ @classmethod
36
+ def signature(cls) -> WebhookSignature:
37
+ # Hex HMAC-SHA256 of the raw body, keyed by the Sentry internal
38
+ # integration's client secret, with no prefix.
39
+ return WebhookSignature(
40
+ header='sentry-hook-signature', prefix='', secret='webhook'
41
+ )
42
+
43
+
44
+ class SentryPlugin(plugins.Plugin):
45
+ """Sentry integration plugin."""
46
+
47
+ manifest = plugins.PluginManifest.model_validate(
48
+ {
49
+ 'slug': 'sentry',
50
+ 'name': 'Sentry',
51
+ 'icon': 'si-sentry',
52
+ 'description': (
53
+ 'Create and link a Sentry project for each project, and keep '
54
+ 'issue-count facts in sync from Sentry webhooks and the '
55
+ 'Project Doctor.'
56
+ ),
57
+ 'auth_type': 'api_token',
58
+ 'options': [
59
+ {
60
+ 'name': 'service_url',
61
+ 'label': 'Sentry URL',
62
+ 'description': (
63
+ 'Base URL of Sentry. Leave blank for '
64
+ 'https://sentry.io.'
65
+ ),
66
+ 'type': 'string',
67
+ },
68
+ {
69
+ 'name': 'organization',
70
+ 'label': 'Sentry organization slug',
71
+ 'type': 'string',
72
+ 'required': True,
73
+ },
74
+ {
75
+ 'name': 'team_mapping',
76
+ 'label': 'Team to Sentry team',
77
+ 'description': (
78
+ 'Maps an Imbi team slug to the Sentry team that owns '
79
+ "the team's new Sentry projects. An unmapped team "
80
+ 'uses its own slug.'
81
+ ),
82
+ 'type': 'mapping',
83
+ },
84
+ {
85
+ 'name': 'issue_count_facts',
86
+ 'label': 'Issue-count facts',
87
+ 'description': (
88
+ 'Maps a project fact name to the Sentry issue search '
89
+ 'whose count it holds, e.g. `sentry_open_issues` to '
90
+ '`is:unresolved`. Each fact must exist as an integer '
91
+ 'property in a project blueprint.'
92
+ ),
93
+ 'type': 'mapping',
94
+ 'default': client.DEFAULT_ISSUE_COUNT_FACTS,
95
+ },
96
+ {
97
+ 'name': 'issue_period',
98
+ 'label': 'Issue search period',
99
+ 'description': (
100
+ 'Sentry statsPeriod the counts cover, e.g. `14d` or '
101
+ '`90d`. The issues link uses the same period.'
102
+ ),
103
+ 'type': 'string',
104
+ 'default': client.DEFAULT_ISSUE_PERIOD,
105
+ },
106
+ ],
107
+ 'credentials': [
108
+ {
109
+ 'name': 'auth_token',
110
+ 'label': 'Sentry auth token',
111
+ 'description': (
112
+ 'Token of a Sentry internal integration with '
113
+ 'Project: Admin (to create projects) and Issue & '
114
+ 'Event: Read.'
115
+ ),
116
+ }
117
+ ],
118
+ 'capabilities': [
119
+ {
120
+ 'kind': 'lifecycle',
121
+ 'label': 'Project lifecycle',
122
+ 'description': (
123
+ 'Create (or link) a Sentry project when a project '
124
+ 'is created.'
125
+ ),
126
+ 'hints': {'lifecycle_events': ['created']},
127
+ 'handler': SentryLifecycle,
128
+ },
129
+ {
130
+ 'kind': 'webhook-actions',
131
+ 'label': 'Webhook actions',
132
+ 'description': (
133
+ 'Sentry issue webhooks dispatched by imbi-gateway.'
134
+ ),
135
+ 'handler': SentryWebhookActions,
136
+ },
137
+ {
138
+ 'kind': 'analysis',
139
+ 'label': 'Project doctor',
140
+ 'description': (
141
+ 'Validate the Sentry project link and issue-count '
142
+ 'facts against Sentry, with one-click repairs.'
143
+ ),
144
+ 'handler': SentryDoctor,
145
+ },
146
+ ],
147
+ }
148
+ )
File without changes
@@ -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,10 @@
1
+ imbi/plugins/sentry/__init__.py,sha256=rzDIHWTGzQiLLlbfkY5s4vkDZ3hvO-bwVz27hOqd524,266
2
+ imbi/plugins/sentry/actions.py,sha256=oGY2X-6xTu0pu3FPLU5PyoPxu4l4pFeCTGChHyby8_w,2853
3
+ imbi/plugins/sentry/client.py,sha256=hrYS09QyRGj5qVeW812wzjarShvYIDvbhDcXgWZ7CBo,9850
4
+ imbi/plugins/sentry/doctor.py,sha256=KfLwb0BzK6fZXUVgZ23xVrJ8mDSA6b1B2cZRhM1r6EA,13097
5
+ imbi/plugins/sentry/lifecycle.py,sha256=ZyKo8O118vXMmv_DoySvZyhSQ76nKDa3MQVqaQ30xaM,2602
6
+ imbi/plugins/sentry/plugin.py,sha256=fI37un9evaRUOIdbsqF-5mAF4oxFrEIBV_pf6mt7Ens,5700
7
+ imbi/plugins/sentry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ imbi_plugin_sentry-2.46.0.dist-info/METADATA,sha256=IFEQ2jpOxrp9OU1iz6kXCsSrulQrgxPsH6BIc6R2lCI,783
9
+ imbi_plugin_sentry-2.46.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
10
+ imbi_plugin_sentry-2.46.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any