imbi-plugin-github 2.11.0__tar.gz → 2.11.3__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.
Files changed (27) hide show
  1. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/pyproject.toml +4 -3
  3. imbi_plugin_github-2.11.3/src/imbi_plugin_github/pull_requests.py +364 -0
  4. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/uv.lock +6 -6
  5. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/.github/workflows/publish.yml +0 -0
  6. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/.github/workflows/test.yml +0 -0
  7. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/.gitignore +0 -0
  8. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/.pre-commit-config.yaml +0 -0
  9. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/CLAUDE.md +0 -0
  10. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/LICENSE +0 -0
  11. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/README.md +0 -0
  12. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/justfile +0 -0
  13. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/src/imbi_plugin_github/__init__.py +0 -0
  14. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/src/imbi_plugin_github/_app_auth.py +0 -0
  15. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/src/imbi_plugin_github/_hosts.py +0 -0
  16. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/src/imbi_plugin_github/_repos.py +0 -0
  17. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/src/imbi_plugin_github/commits.py +0 -0
  18. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/src/imbi_plugin_github/deployment.py +0 -0
  19. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/src/imbi_plugin_github/identity.py +0 -0
  20. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/src/imbi_plugin_github/lifecycle.py +0 -0
  21. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/tests/__init__.py +0 -0
  22. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/tests/test_commits.py +0 -0
  23. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/tests/test_deployment.py +0 -0
  24. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/tests/test_hosts.py +0 -0
  25. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/tests/test_identity.py +0 -0
  26. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/tests/test_lifecycle.py +0 -0
  27. {imbi_plugin_github-2.11.0 → imbi_plugin_github-2.11.3}/tests/test_repos.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imbi-plugin-github
3
- Version: 2.11.0
3
+ Version: 2.11.3
4
4
  Summary: GitHub identity plugin for Imbi (github.com / GHEC / GHES)
5
5
  Author-email: "Gavin M. Roy" <gavinr@aweber.com>
6
6
  License: BSD-3-Clause
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.14
14
14
  Requires-Python: >=3.14
15
15
  Requires-Dist: httpx>=0.27
16
- Requires-Dist: imbi-common[databases]==2.11.0
16
+ Requires-Dist: imbi-common[databases]==2.11.3
17
17
  Requires-Dist: pydantic>=2
18
18
  Requires-Dist: pyjwt[crypto]>=2.8
19
19
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "imbi-plugin-github"
3
- version = "2.11.0"
3
+ version = "2.11.3"
4
4
  description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -16,7 +16,7 @@ classifiers = [
16
16
  ]
17
17
  dependencies = [
18
18
  "httpx>=0.27",
19
- "imbi-common[databases]==2.11.0",
19
+ "imbi-common[databases]==2.11.3",
20
20
  "pydantic>=2",
21
21
  "pyjwt[crypto]>=2.8",
22
22
  ]
@@ -32,12 +32,13 @@ github-lifecycle = "imbi_plugin_github.lifecycle:GitHubLifecyclePlugin"
32
32
  github-lifecycle-ec = "imbi_plugin_github.lifecycle:GitHubEnterpriseCloudLifecyclePlugin"
33
33
  github-lifecycle-es = "imbi_plugin_github.lifecycle:GitHubEnterpriseServerLifecyclePlugin"
34
34
  github-commit-sync = "imbi_plugin_github.commits:GitHubCommitSyncPlugin"
35
+ github-pr-sync = "imbi_plugin_github.pull_requests:GitHubPRSyncPlugin"
35
36
 
36
37
  [dependency-groups]
37
38
  dev = [
38
39
  "basedpyright",
39
40
  "coverage[toml]",
40
- "imbi-common[server,databases]==2.11.0",
41
+ "imbi-common[server,databases]==2.11.3",
41
42
  "pre-commit",
42
43
  "pytest",
43
44
  "pytest-asyncio",
@@ -0,0 +1,364 @@
1
+ """GitHub pull-request history sync (webhook action plugin).
2
+
3
+ :class:`GitHubPRSyncPlugin` exposes one webhook action --
4
+ ``sync_pull_requests`` -- dispatched by ``imbi-gateway`` on
5
+ ``pull_request`` deliveries. It writes directly to the ClickHouse
6
+ ``pull_requests`` table rather than relying on the materialized view
7
+ that previously extracted PR data from ``imbi.events``.
8
+
9
+ The plugin also exposes :meth:`GitHubPRSyncPlugin.sync_all_history`
10
+ for an on-demand, host-invoked backfill: it walks
11
+ ``GET /repos/{owner}/{repo}/pulls?state=all`` and records every PR.
12
+ GitHub's list-PRs API omits per-file diff stats
13
+ (``additions``/``deletions``/``changed_files``), so backfill rows
14
+ store ``0`` for those fields; they are accurate on webhook-driven rows.
15
+
16
+ PR rows are written to the shared ClickHouse ``pull_requests`` table via
17
+ :func:`imbi_common.clickhouse.insert`. Writes are best-effort: a storage
18
+ failure is logged and swallowed so an analytics hiccup never 5xxs the
19
+ webhook, exactly as the gateway's own event recording behaves.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import datetime
25
+ import logging
26
+ import typing
27
+
28
+ import httpx
29
+ import jsonpointer
30
+ import pydantic
31
+ from imbi_common import clickhouse
32
+ from imbi_common.json_pointer import JsonPointer
33
+ from imbi_common.models import PullRequestRecord
34
+ from imbi_common.plugins.base import (
35
+ ActionDescriptor,
36
+ CredentialField,
37
+ PluginContext,
38
+ PluginManifest,
39
+ WebhookActionPlugin,
40
+ )
41
+
42
+ from imbi_plugin_github._hosts import host_to_api_base
43
+ from imbi_plugin_github._repos import resolve_owner_repo
44
+ from imbi_plugin_github.commits import (
45
+ _BACKFILL_MAX_WAIT_SECONDS, # pyright: ignore[reportPrivateUsage]
46
+ _client, # pyright: ignore[reportPrivateUsage]
47
+ _insert_best_effort, # pyright: ignore[reportPrivateUsage]
48
+ _request, # pyright: ignore[reportPrivateUsage]
49
+ _resolve, # pyright: ignore[reportPrivateUsage]
50
+ _resolve_bearer, # pyright: ignore[reportPrivateUsage]
51
+ _resolve_host_for_context, # pyright: ignore[reportPrivateUsage]
52
+ )
53
+ from imbi_plugin_github.deployment import (
54
+ _next_page_url, # pyright: ignore[reportPrivateUsage]
55
+ _parse_iso, # pyright: ignore[reportPrivateUsage]
56
+ _query_param, # pyright: ignore[reportPrivateUsage]
57
+ )
58
+
59
+ LOGGER = logging.getLogger(__name__)
60
+
61
+ _SELF_SLUG = 'github-pr-sync'
62
+ # GitHub's pulls list is 100 per page; cap at 100 pages = 10k PRs so a
63
+ # very large repo can't pin a one-shot backfill indefinitely.
64
+ _MAX_HISTORY_PAGES = 100
65
+ # Webhook actions handle these PR lifecycle transitions; synchronize
66
+ # fires when new commits are pushed to the PR branch (title/state may
67
+ # also change).
68
+ _SYNC_ACTIONS = frozenset({'opened', 'closed', 'reopened', 'synchronize'})
69
+
70
+
71
+ def _parse_pr_datetime(value: object) -> datetime.datetime | None:
72
+ """Parse a GitHub ISO timestamp or return ``None`` for null/missing."""
73
+ if not isinstance(value, str) or not value:
74
+ return None
75
+ try:
76
+ return _parse_iso(value)
77
+ except Exception: # noqa: BLE001
78
+ return None
79
+
80
+
81
+ def _pr_record(
82
+ pr: dict[str, typing.Any],
83
+ *,
84
+ project_id: str,
85
+ ) -> PullRequestRecord | None:
86
+ """Map a GitHub PR object onto a :class:`PullRequestRecord`.
87
+
88
+ Returns ``None`` if the PR object is missing required fields (id,
89
+ number, created_at) so malformed payloads are silently skipped.
90
+ """
91
+ pr_id = pr.get('id')
92
+ pr_number = pr.get('number')
93
+ created_at_raw = pr.get('created_at')
94
+ if not pr_id or not pr_number or not created_at_raw:
95
+ return None
96
+ created_at = _parse_pr_datetime(created_at_raw)
97
+ if created_at is None:
98
+ return None
99
+ updated_at = _parse_pr_datetime(pr.get('updated_at')) or created_at
100
+ user: dict[str, typing.Any] = pr.get('user') or {}
101
+ return PullRequestRecord(
102
+ project_id=project_id,
103
+ pr_id=str(pr_id),
104
+ pr_number=int(pr_number),
105
+ title=str(pr.get('title') or ''),
106
+ url=str(pr.get('html_url') or ''),
107
+ state=str(pr.get('state') or 'open'),
108
+ author=str(user.get('login') or ''),
109
+ draft=bool(pr.get('draft', False)),
110
+ merged=bool(pr.get('merged', False)),
111
+ created_at=created_at,
112
+ updated_at=updated_at,
113
+ merged_at=_parse_pr_datetime(pr.get('merged_at')),
114
+ additions=int(pr.get('additions') or 0),
115
+ deletions=int(pr.get('deletions') or 0),
116
+ changed_files=int(pr.get('changed_files') or 0),
117
+ )
118
+
119
+
120
+ async def _fetch_all_prs(
121
+ client: httpx.AsyncClient,
122
+ *,
123
+ max_wait: float,
124
+ ) -> list[dict[str, typing.Any]]:
125
+ """Walk ``GET /pulls?state=all`` and return every PR object.
126
+
127
+ GitHub's list-PRs endpoint omits ``additions``, ``deletions``, and
128
+ ``changed_files``; backfill rows store ``0`` for those fields.
129
+ Caps at ``_MAX_HISTORY_PAGES`` pages (10k PRs) so a repo with a very
130
+ large PR history can't pin the worker indefinitely.
131
+ """
132
+ out: list[dict[str, typing.Any]] = []
133
+ params: dict[str, str] = {'state': 'all', 'per_page': '100'}
134
+ for _ in range(_MAX_HISTORY_PAGES):
135
+ resp = await _request(
136
+ client, 'GET', '/pulls', params=params, max_wait=max_wait
137
+ )
138
+ resp.raise_for_status()
139
+ page: list[dict[str, typing.Any]] = resp.json()
140
+ if not page:
141
+ break
142
+ out.extend(page)
143
+ next_url = _next_page_url(resp.headers.get('link'))
144
+ if next_url is None:
145
+ break
146
+ next_page = _query_param(next_url, 'page')
147
+ if next_page is None:
148
+ break
149
+ params['page'] = next_page
150
+ else:
151
+ LOGGER.warning(
152
+ 'github-pr-sync: reached %d-page cap fetching PRs; '
153
+ 'history may be truncated',
154
+ _MAX_HISTORY_PAGES,
155
+ )
156
+ return out
157
+
158
+
159
+ class SyncPRsConfig(pydantic.BaseModel):
160
+ """``WebhookRule.handler_config`` for ``sync_pull_requests``.
161
+
162
+ Selectors resolve against the event context; the PR body lives
163
+ under ``/payload`` (e.g. ``/payload/action``).
164
+ """
165
+
166
+ action_selector: JsonPointer = pydantic.Field(
167
+ default_factory=lambda: jsonpointer.JsonPointer('/payload/action')
168
+ )
169
+ pr_selector: JsonPointer = pydantic.Field(
170
+ default_factory=lambda: jsonpointer.JsonPointer(
171
+ '/payload/pull_request'
172
+ )
173
+ )
174
+ repository_selector: JsonPointer = pydantic.Field(
175
+ default_factory=lambda: jsonpointer.JsonPointer(
176
+ '/payload/repository/full_name'
177
+ )
178
+ )
179
+ api_base_url: str | None = None
180
+ repo_api_url_selector: JsonPointer = pydantic.Field(
181
+ default_factory=lambda: jsonpointer.JsonPointer(
182
+ '/payload/repository/url'
183
+ )
184
+ )
185
+
186
+
187
+ async def sync_pull_requests(
188
+ *,
189
+ ctx: PluginContext,
190
+ credentials: dict[str, str],
191
+ external_identifier: str,
192
+ action_config: SyncPRsConfig,
193
+ event: object,
194
+ ) -> None:
195
+ """Sync a pull_request event delivery into the ``pull_requests`` table.
196
+
197
+ Handles ``opened``, ``closed``, ``reopened``, and ``synchronize``
198
+ actions. Other actions (e.g. ``labeled``, ``review_requested``) are
199
+ silently ignored -- the rule's CEL filter can pre-screen them, but
200
+ this action is defensive too.
201
+
202
+ The full PR object is in the webhook payload so no extra GitHub API
203
+ call is needed.
204
+ """
205
+ del external_identifier
206
+ action = _resolve(action_config.action_selector, event)
207
+ if not isinstance(action, str) or action not in _SYNC_ACTIONS:
208
+ return
209
+ pr_raw = _resolve(action_config.pr_selector, event)
210
+ if not isinstance(pr_raw, dict):
211
+ return
212
+ pr_obj = typing.cast('dict[str, typing.Any]', pr_raw)
213
+ record = _pr_record(pr_obj, project_id=ctx.project_id)
214
+ if record is None:
215
+ LOGGER.warning(
216
+ 'github-pr-sync: skipping malformed PR payload for project %s '
217
+ '(action=%s, missing required fields)',
218
+ ctx.project_id,
219
+ action,
220
+ )
221
+ return
222
+ try:
223
+ await clickhouse.insert('pull_requests', [record])
224
+ except Exception:
225
+ LOGGER.exception(
226
+ 'github-pr-sync: failed to record PR #%d for project %s',
227
+ record.pr_number,
228
+ ctx.project_id,
229
+ )
230
+
231
+
232
+ sync_pull_requests_descriptor = ActionDescriptor(
233
+ name='sync_pull_requests',
234
+ label='Sync Pull Request History',
235
+ description=(
236
+ 'Record pull_request webhook events (opened, closed, reopened, '
237
+ 'synchronize) directly into the ClickHouse pull_requests table.'
238
+ ),
239
+ callable=typing.cast(
240
+ 'typing.Any',
241
+ 'imbi_plugin_github.pull_requests:sync_pull_requests',
242
+ ),
243
+ config_model=typing.cast(
244
+ 'typing.Any',
245
+ 'imbi_plugin_github.pull_requests:SyncPRsConfig',
246
+ ),
247
+ )
248
+
249
+
250
+ class GitHubPRSyncPlugin(WebhookActionPlugin):
251
+ """Webhook-action plugin syncing GitHub pull-request history.
252
+
253
+ Carries its own service credential (PAT or GitHub App) -- it is not
254
+ folded into the identity / deployment / lifecycle plugins, which run
255
+ as the acting user. Auth is resolved identically to
256
+ :class:`~imbi_plugin_github.commits.GitHubCommitSyncPlugin`.
257
+ """
258
+
259
+ manifest = PluginManifest(
260
+ slug='github-pr-sync',
261
+ name='GitHub Pull Request History Sync',
262
+ description=(
263
+ 'Syncs pull request history from GitHub webhooks into '
264
+ 'ClickHouse for analytics. Also supports on-demand backfill '
265
+ 'of the full PR history via sync_all_history.'
266
+ ),
267
+ plugin_type='webhook',
268
+ credentials=[
269
+ CredentialField(
270
+ name='access_token',
271
+ label='GitHub Token (PAT)',
272
+ description=(
273
+ 'Static personal/service access token. Use this *or* '
274
+ 'the GitHub App fields below.'
275
+ ),
276
+ required=False,
277
+ ),
278
+ CredentialField(
279
+ name='app_id',
280
+ label='GitHub App ID',
281
+ description=(
282
+ 'GitHub App identifier; with a private key the plugin '
283
+ 'mints short-lived installation tokens.'
284
+ ),
285
+ required=False,
286
+ ),
287
+ CredentialField(
288
+ name='private_key',
289
+ label='GitHub App Private Key',
290
+ description=(
291
+ 'App private key, raw PEM or base64-encoded PEM.'
292
+ ),
293
+ required=False,
294
+ ),
295
+ CredentialField(
296
+ name='installation_id',
297
+ label='GitHub App Installation ID',
298
+ description=(
299
+ 'Optional. When unset, the installation is discovered '
300
+ 'from the repository.'
301
+ ),
302
+ required=False,
303
+ ),
304
+ ],
305
+ )
306
+
307
+ @classmethod
308
+ def actions(cls) -> list[ActionDescriptor]:
309
+ return [sync_pull_requests_descriptor]
310
+
311
+ async def sync_all_history(
312
+ self,
313
+ *,
314
+ ctx: PluginContext,
315
+ credentials: dict[str, str],
316
+ ) -> int:
317
+ """Record the project's full pull request history.
318
+
319
+ Host-invoked (no webhook payload): the host instantiates the
320
+ plugin, builds a :class:`PluginContext` carrying the project's
321
+ links and the connected ``service_plugins``, resolves this
322
+ plugin's service ``credentials``, and awaits this method. The
323
+ GitHub host/flavor is read from ``service_plugins``, the
324
+ ``(owner, repo)`` from the project links, and the bearer token
325
+ from the same PAT-or-App resolution the webhook actions use.
326
+
327
+ Walks every PR in the repo (``state=all``, paginated) and upserts
328
+ into the ClickHouse ``pull_requests`` table.
329
+ ``ReplacingMergeTree`` dedupes against rows the webhook already
330
+ recorded, so re-running is safe.
331
+
332
+ ``additions``, ``deletions``, and ``changed_files`` are ``0`` for
333
+ backfill rows (GitHub's list-PRs API omits them); they are
334
+ accurate for any future webhook-driven rows.
335
+
336
+ Returns the number of PRs recorded. Raises :class:`ValueError`
337
+ only when the host or repository can't be resolved; ClickHouse
338
+ failures are swallowed (the count reflects what was written).
339
+ Propagates :class:`PluginRateLimited` when a GitHub rate-limit
340
+ reset is further out than ``_BACKFILL_MAX_WAIT_SECONDS`` so the
341
+ host can pause the worker and keep the job queued until GitHub
342
+ resumes rather than fail it.
343
+ """
344
+ host = _resolve_host_for_context(ctx)
345
+ if host is None:
346
+ raise ValueError(
347
+ 'github-pr-sync could not resolve a GitHub host for an '
348
+ 'on-demand sync: connect a GitHub plugin to the service'
349
+ )
350
+ base = host_to_api_base(host)
351
+ owner, repo = resolve_owner_repo(ctx, host, _SELF_SLUG)
352
+ token = await _resolve_bearer(credentials, base, owner, repo)
353
+ async with _client(base, owner, repo, token) as client:
354
+ raw_prs = await _fetch_all_prs(
355
+ client, max_wait=_BACKFILL_MAX_WAIT_SECONDS
356
+ )
357
+ records: list[pydantic.BaseModel] = []
358
+ for pr in raw_prs:
359
+ record = _pr_record(pr, project_id=ctx.project_id)
360
+ if record is not None:
361
+ records.append(record)
362
+ return await _insert_best_effort(
363
+ 'pull_requests', records, ctx.project_id
364
+ )
@@ -667,7 +667,7 @@ wheels = [
667
667
 
668
668
  [[package]]
669
669
  name = "imbi-common"
670
- version = "2.11.0"
670
+ version = "2.11.3"
671
671
  source = { registry = "https://pypi.org/simple" }
672
672
  dependencies = [
673
673
  { name = "cryptography" },
@@ -683,9 +683,9 @@ dependencies = [
683
683
  { name = "python-slugify" },
684
684
  { name = "typer" },
685
685
  ]
686
- sdist = { url = "https://files.pythonhosted.org/packages/82/02/30fe936f86936763fd0460c0bf7a8602029ee32de76052063f1322eb54cb/imbi_common-2.11.0.tar.gz", hash = "sha256:fd9126d2ae80fed72902bdcd280a17f992eff6bb80caad7f8979cc07f17ce946", size = 350702, upload-time = "2026-06-11T17:39:16.348Z" }
686
+ sdist = { url = "https://files.pythonhosted.org/packages/89/ab/e60f6d52bccfdf557de08169014e898c5f5b6e5d55633d77bf1386f7a110/imbi_common-2.11.3.tar.gz", hash = "sha256:b6e10de8c97726e74b4b779558dc8e228f76fb447adce231aeaeb96d07d95e7a", size = 349524, upload-time = "2026-06-12T00:35:18.234Z" }
687
687
  wheels = [
688
- { url = "https://files.pythonhosted.org/packages/b4/5d/6adecad7cf2fa1fa0f35e7ca37352f05d504824ba785f6775750f3e442fc/imbi_common-2.11.0-py3-none-any.whl", hash = "sha256:8ca15781dc0285ce412b9158f0162739a9ae82229b193ccf473b01e62688c923", size = 113762, upload-time = "2026-06-11T17:39:14.999Z" },
688
+ { url = "https://files.pythonhosted.org/packages/28/6d/00a22fde24df9f13b23edb6688f1875b308db82dc9d3ed48cd6d70f955bb/imbi_common-2.11.3-py3-none-any.whl", hash = "sha256:f788e5e966b5fd55030b3af32915fbfed16474ad55c9a86257ef2fd7fcb3663e", size = 113174, upload-time = "2026-06-12T00:35:16.674Z" },
689
689
  ]
690
690
 
691
691
  [package.optional-dependencies]
@@ -703,7 +703,7 @@ server = [
703
703
 
704
704
  [[package]]
705
705
  name = "imbi-plugin-github"
706
- version = "2.11.0"
706
+ version = "2.11.3"
707
707
  source = { editable = "." }
708
708
  dependencies = [
709
709
  { name = "httpx" },
@@ -732,7 +732,7 @@ dist = [
732
732
  [package.metadata]
733
733
  requires-dist = [
734
734
  { name = "httpx", specifier = ">=0.27" },
735
- { name = "imbi-common", extras = ["databases"], specifier = "==2.11.0" },
735
+ { name = "imbi-common", extras = ["databases"], specifier = "==2.11.3" },
736
736
  { name = "pydantic", specifier = ">=2" },
737
737
  { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8" },
738
738
  ]
@@ -741,7 +741,7 @@ requires-dist = [
741
741
  dev = [
742
742
  { name = "basedpyright" },
743
743
  { name = "coverage", extras = ["toml"] },
744
- { name = "imbi-common", extras = ["server", "databases"], specifier = "==2.11.0" },
744
+ { name = "imbi-common", extras = ["server", "databases"], specifier = "==2.11.3" },
745
745
  { name = "pre-commit" },
746
746
  { name = "pytest" },
747
747
  { name = "pytest-asyncio" },