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.
- imbi/plugins/sentry/__init__.py +9 -0
- imbi/plugins/sentry/actions.py +90 -0
- imbi/plugins/sentry/client.py +298 -0
- imbi/plugins/sentry/doctor.py +383 -0
- imbi/plugins/sentry/lifecycle.py +74 -0
- imbi/plugins/sentry/plugin.py +148 -0
- imbi/plugins/sentry/py.typed +0 -0
- imbi_plugin_sentry-2.46.0.dist-info/METADATA +22 -0
- imbi_plugin_sentry-2.46.0.dist-info/RECORD +10 -0
- imbi_plugin_sentry-2.46.0.dist-info/WHEEL +4 -0
|
@@ -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,,
|