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.
- imbi_plugin_sentry-2.46.0/.gitignore +30 -0
- imbi_plugin_sentry-2.46.0/PKG-INFO +22 -0
- imbi_plugin_sentry-2.46.0/README.md +4 -0
- imbi_plugin_sentry-2.46.0/moon.yml +22 -0
- imbi_plugin_sentry-2.46.0/pyproject.toml +45 -0
- imbi_plugin_sentry-2.46.0/src/imbi/plugins/sentry/__init__.py +9 -0
- imbi_plugin_sentry-2.46.0/src/imbi/plugins/sentry/actions.py +90 -0
- imbi_plugin_sentry-2.46.0/src/imbi/plugins/sentry/client.py +298 -0
- imbi_plugin_sentry-2.46.0/src/imbi/plugins/sentry/doctor.py +383 -0
- imbi_plugin_sentry-2.46.0/src/imbi/plugins/sentry/lifecycle.py +74 -0
- imbi_plugin_sentry-2.46.0/src/imbi/plugins/sentry/plugin.py +148 -0
- imbi_plugin_sentry-2.46.0/src/imbi/plugins/sentry/py.typed +0 -0
- imbi_plugin_sentry-2.46.0/tests/__init__.py +0 -0
- imbi_plugin_sentry-2.46.0/tests/test_plugin.py +345 -0
|
@@ -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,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))
|