imbi-plugin-github 1.1.0__tar.gz → 1.3.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.
Files changed (21) hide show
  1. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/PKG-INFO +6 -4
  2. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/README.md +4 -2
  3. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/pyproject.toml +8 -8
  4. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/src/imbi_plugin_github/deployment.py +220 -37
  5. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/tests/test_deployment.py +277 -50
  6. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/uv.lock +36 -26
  7. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/.github/workflows/publish.yml +0 -0
  8. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/.github/workflows/test.yml +0 -0
  9. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/.gitignore +0 -0
  10. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/.pre-commit-config.yaml +0 -0
  11. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/LICENSE +0 -0
  12. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/justfile +0 -0
  13. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/src/imbi_plugin_github/__init__.py +0 -0
  14. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/src/imbi_plugin_github/_hosts.py +0 -0
  15. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/src/imbi_plugin_github/_repos.py +0 -0
  16. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/src/imbi_plugin_github/identity.py +0 -0
  17. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/src/imbi_plugin_github/lifecycle.py +0 -0
  18. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/tests/__init__.py +0 -0
  19. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/tests/test_hosts.py +0 -0
  20. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/tests/test_identity.py +0 -0
  21. {imbi_plugin_github-1.1.0 → imbi_plugin_github-1.3.0}/tests/test_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: imbi-plugin-github
3
- Version: 1.1.0
3
+ Version: 1.3.0
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>=0.11.0
16
+ Requires-Dist: imbi-common>=2.5.5
17
17
  Requires-Dist: pydantic>=2
18
18
  Description-Content-Type: text/markdown
19
19
 
@@ -40,8 +40,10 @@ to GitHub APIs as a `Bearer` token, so `materialize()` is a no-op.
40
40
  ### Deployment
41
41
 
42
42
  Drives the GitHub Deployments API (`POST /repos/{owner}/{repo}/deployments`)
43
- plus tag and release creation. See `deployment.py` for the per-environment
44
- `DEPLOYS_VIA` edge contract.
43
+ plus tag and release creation. Promote behaviour is inferred from the
44
+ ref shape by the host (semver → trigger Deployment, raw SHA → cut tag
45
+ + Release). Per-env workflow inputs ride on the `USES_PLUGIN` edge as
46
+ `env_payloads` and arrive on `PluginContext.environment_config`.
45
47
 
46
48
  ### Lifecycle
47
49
 
@@ -21,8 +21,10 @@ to GitHub APIs as a `Bearer` token, so `materialize()` is a no-op.
21
21
  ### Deployment
22
22
 
23
23
  Drives the GitHub Deployments API (`POST /repos/{owner}/{repo}/deployments`)
24
- plus tag and release creation. See `deployment.py` for the per-environment
25
- `DEPLOYS_VIA` edge contract.
24
+ plus tag and release creation. Promote behaviour is inferred from the
25
+ ref shape by the host (semver → trigger Deployment, raw SHA → cut tag
26
+ + Release). Per-env workflow inputs ride on the `USES_PLUGIN` edge as
27
+ `env_payloads` and arrive on `PluginContext.environment_config`.
26
28
 
27
29
  ### Lifecycle
28
30
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "imbi-plugin-github"
3
- version = "1.1.0"
3
+ version = "1.3.0"
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>=0.11.0",
19
+ "imbi-common>=2.5.5",
20
20
  "pydantic>=2",
21
21
  ]
22
22
 
@@ -48,9 +48,6 @@ dist = ["build", "twine", "wheel"]
48
48
  requires = ["hatchling"]
49
49
  build-backend = "hatchling.build"
50
50
 
51
- [tool.hatch.build.targets.wheel]
52
- packages = ["src/imbi_plugin_github"]
53
-
54
51
  [tool.coverage.report]
55
52
  exclude_also = ["typing.TYPE_CHECKING"]
56
53
  fail_under = 85
@@ -60,9 +57,8 @@ show_missing = true
60
57
  branch = true
61
58
  source = ["src/imbi_plugin_github"]
62
59
 
63
- [tool.pytest.ini_options]
64
- asyncio_mode = "auto"
65
- asyncio_default_fixture_loop_scope = "function"
60
+ [tool.hatch.build.targets.wheel]
61
+ packages = ["src/imbi_plugin_github"]
66
62
 
67
63
  [tool.pyright]
68
64
  executionEnvironments = [
@@ -74,6 +70,10 @@ reportMissingSuperCall = "hint"
74
70
  reportMissingTypeStubs = false
75
71
  typeCheckingMode = "strict"
76
72
 
73
+ [tool.pytest.ini_options]
74
+ asyncio_mode = "auto"
75
+ asyncio_default_fixture_loop_scope = "function"
76
+
77
77
  [tool.ruff]
78
78
  line-length = 79
79
79
  target-version = "py314"
@@ -38,8 +38,10 @@ from imbi_common.plugins.base import (
38
38
  Commit,
39
39
  CompareResult,
40
40
  CredentialField,
41
+ DeploymentEventStatus,
41
42
  DeploymentPlugin,
42
43
  DeploymentRun,
44
+ OpsLogTemplate,
43
45
  PluginContext,
44
46
  PluginEdgeLabel,
45
47
  PluginManifest,
@@ -47,6 +49,7 @@ from imbi_common.plugins.base import (
47
49
  Ref,
48
50
  RefInfo,
49
51
  ReleaseInfo,
52
+ RemoteDeployment,
50
53
  WorkflowFile,
51
54
  )
52
55
  from imbi_common.plugins.errors import PluginAuthenticationFailed
@@ -669,8 +672,9 @@ class _DeploymentBase(DeploymentPlugin):
669
672
  promote refs are often freshly-cut tags whose CI hasn't run yet,
670
673
  and the deploy workflow itself is what we're waiting on.
671
674
 
672
- Payload precedence (highest wins): per-env edge ``payload`` from
673
- ``ctx.environment_config['payload']`` over ``inputs`` from the
675
+ Payload precedence (lowest → highest): plugin assignment
676
+ ``env_payloads[env_slug]`` (carried by the host on
677
+ ``ctx.environment_config``) below the ``inputs`` map from the
674
678
  caller. The ``ref`` and ``environment`` are not part of the
675
679
  payload — they're top-level fields on the deployment object.
676
680
  """
@@ -678,12 +682,9 @@ class _DeploymentBase(DeploymentPlugin):
678
682
  raise ValueError(
679
683
  'trigger_deployment requires PluginContext.environment'
680
684
  )
681
- merged_payload: dict[str, typing.Any] = dict(inputs or {})
682
- env_payload = ctx.environment_config.get('payload')
683
- if isinstance(env_payload, dict):
684
- merged_payload.update(
685
- typing.cast(dict[str, typing.Any], env_payload)
686
- )
685
+ merged_payload: dict[str, typing.Any] = dict(ctx.environment_config)
686
+ if inputs:
687
+ merged_payload.update(inputs)
687
688
  async with self._client(ctx, credentials) as client:
688
689
  resp = await client.post(
689
690
  '/deployments',
@@ -717,7 +718,7 @@ class _DeploymentBase(DeploymentPlugin):
717
718
  """List ``.github/workflows/*.yml`` registered for the repo.
718
719
 
719
720
  Used by the UI to populate a workflow dropdown when an operator
720
- wires up the per-environment ``DEPLOYS_VIA`` edge. Returns only
721
+ configures plugin assignment ``env_payloads``. Returns only
721
722
  ``active`` workflows by default; callers that need disabled
722
723
  entries can filter the result themselves. GitHub caps the
723
724
  ``/actions/workflows`` page at 100 — that's more than enough for
@@ -839,37 +840,186 @@ class _DeploymentBase(DeploymentPlugin):
839
840
  else None,
840
841
  )
841
842
 
843
+ async def list_recent_deployments(
844
+ self,
845
+ ctx: PluginContext,
846
+ credentials: dict[str, str],
847
+ environments: list[str],
848
+ limit: int = 1,
849
+ ) -> list[RemoteDeployment]:
850
+ """Return the latest ``limit`` deployments per environment.
851
+
852
+ Fans out one ``GET /deployments?environment={env}`` call per
853
+ requested environment in parallel, then for each returned
854
+ deployment fetches the latest status via ``GET
855
+ /deployments/{id}/statuses?per_page=1``. Environments the
856
+ remote does not recognise are silently skipped so a partial
857
+ resync still returns the deployments that do exist (an env
858
+ the repo simply hasn't deployed to yet is the common case).
859
+
860
+ The host calls this from the resync flow only when webhook
861
+ delivery has lapsed, so we keep the fan-out modest (``limit=1``
862
+ is the host's default) and let the host walk further history
863
+ with explicit pagination if it ever needs to.
864
+ """
865
+ page_size = max(1, min(limit, 100))
866
+ async with self._client(ctx, credentials) as client:
867
+ per_env = await asyncio.gather(
868
+ *(
869
+ self._list_deployments_for_env(client, env, page_size)
870
+ for env in environments
871
+ )
872
+ )
873
+ return [observed for group in per_env for observed in group]
874
+
875
+ async def _list_deployments_for_env(
876
+ self,
877
+ client: httpx.AsyncClient,
878
+ environment: str,
879
+ page_size: int,
880
+ ) -> list[RemoteDeployment]:
881
+ try:
882
+ resp = await client.get(
883
+ '/deployments',
884
+ params={
885
+ 'environment': environment,
886
+ 'per_page': str(page_size),
887
+ },
888
+ )
889
+ if resp.status_code == 404:
890
+ # Repo or environment unknown on the remote — treat as
891
+ # "nothing to backfill" rather than failing the resync.
892
+ return []
893
+ resp.raise_for_status()
894
+ except httpx.HTTPError:
895
+ LOGGER.warning(
896
+ 'Failed to list deployments for env=%s',
897
+ environment,
898
+ exc_info=True,
899
+ )
900
+ return []
901
+ try:
902
+ deployments = typing.cast(list[dict[str, typing.Any]], resp.json())
903
+ except ValueError:
904
+ LOGGER.warning(
905
+ 'Failed to parse deployments payload for env=%s',
906
+ environment,
907
+ )
908
+ return []
909
+ observed: list[RemoteDeployment] = []
910
+ for deployment in deployments:
911
+ run = await self._observe_deployment(
912
+ client, environment, deployment
913
+ )
914
+ if run is not None:
915
+ observed.append(run)
916
+ return observed
917
+
918
+ async def _observe_deployment(
919
+ self,
920
+ client: httpx.AsyncClient,
921
+ environment: str,
922
+ deployment: dict[str, typing.Any],
923
+ ) -> RemoteDeployment | None:
924
+ deployment_id = deployment.get('id')
925
+ sha = deployment.get('sha')
926
+ if not deployment_id or not sha:
927
+ # GitHub always returns both, but defend the resync path
928
+ # against a malformed response — we'd rather skip one row
929
+ # than corrupt the graph by inventing identifiers.
930
+ return None
931
+ created_at = _parse_iso(deployment.get('created_at')) or (
932
+ datetime.datetime.now(datetime.UTC)
933
+ )
934
+ status, status_url = await self._latest_status(
935
+ client, str(deployment_id)
936
+ )
937
+ ref_value = deployment.get('ref')
938
+ description = deployment.get('description')
939
+ deployment_url = deployment.get('url') or deployment.get('html_url')
940
+ creator_login: str | None = None
941
+ creator_raw = deployment.get('creator')
942
+ if isinstance(creator_raw, dict):
943
+ creator_dict = typing.cast(dict[str, typing.Any], creator_raw)
944
+ login = creator_dict.get('login')
945
+ if isinstance(login, str) and login:
946
+ creator_login = login
947
+ return RemoteDeployment(
948
+ environment=environment,
949
+ sha=str(sha),
950
+ ref=str(ref_value) if ref_value else None,
951
+ status=status,
952
+ created_at=created_at,
953
+ external_run_id=str(deployment_id),
954
+ run_url=status_url,
955
+ deployment_url=str(deployment_url) if deployment_url else None,
956
+ description=str(description) if description else None,
957
+ creator=creator_login,
958
+ )
959
+
960
+ async def _latest_status(
961
+ self, client: httpx.AsyncClient, deployment_id: str
962
+ ) -> tuple[DeploymentEventStatus, str | None]:
963
+ """Return the canonical event status + workflow log URL.
964
+
965
+ Falls back to ``'pending'`` whenever the deploy workflow has
966
+ not yet posted a status: a freshly-created deployment with no
967
+ statuses is structurally identical to one whose workflow has
968
+ not started, and ``pending`` is the host's vocabulary for
969
+ both. Network / parse errors degrade the same way so resync
970
+ is never blocked by a single noisy row.
971
+ """
972
+ try:
973
+ resp = await client.get(
974
+ f'/deployments/{deployment_id}/statuses',
975
+ params={'per_page': '1'},
976
+ )
977
+ except httpx.HTTPError:
978
+ return 'pending', None
979
+ if resp.status_code != 200:
980
+ return 'pending', None
981
+ try:
982
+ statuses = typing.cast(list[dict[str, typing.Any]], resp.json())
983
+ except ValueError:
984
+ return 'pending', None
985
+ if not statuses:
986
+ return 'pending', None
987
+ latest = statuses[0]
988
+ state = str(latest.get('state') or '').lower()
989
+ log_url = latest.get('log_url') or latest.get('target_url')
990
+ return _to_event_status(state), str(log_url) if log_url else None
991
+
992
+
993
+ def _to_event_status(github_state: str) -> DeploymentEventStatus:
994
+ """Map a GitHub deployment-status ``state`` to the host vocabulary.
995
+
996
+ Unknown states fold to ``pending`` rather than raising so a single
997
+ novel value on the remote does not break resync for the whole
998
+ project. ``inactive`` on GitHub means a newer deployment for the
999
+ same environment superseded this one, which the host models as
1000
+ ``rolled_back`` on the ``DeploymentEvent``.
1001
+ """
1002
+ if github_state in {'pending', 'queued', 'waiting'}:
1003
+ return 'pending'
1004
+ if github_state == 'in_progress':
1005
+ return 'in_progress'
1006
+ if github_state == 'success':
1007
+ return 'success'
1008
+ if github_state in {'failure', 'error'}:
1009
+ return 'failed'
1010
+ if github_state == 'inactive':
1011
+ return 'rolled_back'
1012
+ return 'pending'
1013
+
842
1014
 
843
1015
  _COMMON_OPTIONS: list[PluginOption] = []
844
1016
 
845
- _COMMON_EDGE_LABELS: list[PluginEdgeLabel] = [
846
- PluginEdgeLabel(
847
- name='DEPLOYS_VIA',
848
- # The edge can hang off the ProjectType (the usual case — every
849
- # python-api project deploys to staging via release, etc.) or a
850
- # specific Project (rare override slot).
851
- from_labels=['ProjectType', 'Project'],
852
- to_labels=['Environment'],
853
- properties={
854
- # 'release' -> create_tag + create_release; no deployment
855
- # (the repo's ``on: release: [published]``
856
- # trigger handles it).
857
- # 'deployment' -> POST /deployments only (no tag/release).
858
- 'action': 'str',
859
- # Free-form key/value pairs forwarded as the GitHub
860
- # deployment ``payload``. The deploy workflow reads them
861
- # via ``github.event.deployment.payload`` -- so this is
862
- # where operators stash things like a target cluster name,
863
- # a feature flag bundle, etc. Empty by default.
864
- 'payload': 'dict[str, str]',
865
- # Per-environment identity override. Lets ``production``
866
- # post the deployment as a different OAuth identity than
867
- # ``staging`` -- handy when prod requires a service
868
- # account with extra deploy scopes.
869
- 'identity_plugin_id': 'str',
870
- },
871
- ),
872
- ]
1017
+ # Promote behaviour is now inferred from the ``body.tag`` shape on the
1018
+ # imbi-api side: semver tags trigger a Deployment, raw SHAs cut a tag
1019
+ # + GitHub Release. Per-env workflow input overrides live on the
1020
+ # ``USES_PLUGIN`` edge under ``env_payloads`` (keyed by env slug),
1021
+ # resolved by the host and passed in via ``ctx.environment_config``.
1022
+ _COMMON_EDGE_LABELS: list[PluginEdgeLabel] = []
873
1023
 
874
1024
  _COMMON_CREDENTIALS: list[CredentialField] = [
875
1025
  CredentialField(
@@ -884,6 +1034,33 @@ _COMMON_CREDENTIALS: list[CredentialField] = [
884
1034
  ]
885
1035
 
886
1036
 
1037
+ # Templates for the operations-log JSON payload the API writes from
1038
+ # ``_record_deployment_event`` in imbi-api: ``{action, plugin_slug,
1039
+ # run_url, release_url, from_environment}``. Row-level fields
1040
+ # ``version`` and ``environment`` (from the entry's
1041
+ # ``environment_slug``/``environment.name``) are also in scope.
1042
+ _COMMON_OPS_LOG_TEMPLATES: dict[str, OpsLogTemplate] = {
1043
+ 'deploy': OpsLogTemplate(
1044
+ label='Deployed {{version}} to {{environment}}',
1045
+ summary='deployed',
1046
+ ),
1047
+ 'redeploy': OpsLogTemplate(
1048
+ label='Re-deployed {{version}} to {{environment}}',
1049
+ summary='re-deployed',
1050
+ ),
1051
+ 'promote': OpsLogTemplate(
1052
+ label=(
1053
+ 'Promoted {{from_environment}} to {{environment}} as {{version}}.'
1054
+ ),
1055
+ summary='promoted',
1056
+ ),
1057
+ 'resync': OpsLogTemplate(
1058
+ label='Recorded {{version}} deploy in {{environment}}',
1059
+ summary='recorded a deploy in',
1060
+ ),
1061
+ }
1062
+
1063
+
887
1064
  class GitHubDeploymentPlugin(_DeploymentBase):
888
1065
  manifest = PluginManifest(
889
1066
  slug='github-deployment',
@@ -896,9 +1073,11 @@ class GitHubDeploymentPlugin(_DeploymentBase):
896
1073
  'server-side.'
897
1074
  ),
898
1075
  plugin_type='deployment',
1076
+ supports_deployment_sync=True,
899
1077
  options=_COMMON_OPTIONS,
900
1078
  credentials=_COMMON_CREDENTIALS,
901
1079
  edge_labels=_COMMON_EDGE_LABELS,
1080
+ ops_log_templates=_COMMON_OPS_LOG_TEMPLATES,
902
1081
  )
903
1082
 
904
1083
  @classmethod
@@ -916,6 +1095,7 @@ class GitHubEnterpriseCloudDeploymentPlugin(_DeploymentBase):
916
1095
  '``on: deployment_status``) in their deploy workflow.'
917
1096
  ),
918
1097
  plugin_type='deployment',
1098
+ supports_deployment_sync=True,
919
1099
  options=[
920
1100
  PluginOption(
921
1101
  name='host',
@@ -928,6 +1108,7 @@ class GitHubEnterpriseCloudDeploymentPlugin(_DeploymentBase):
928
1108
  ],
929
1109
  credentials=_COMMON_CREDENTIALS,
930
1110
  edge_labels=_COMMON_EDGE_LABELS,
1111
+ ops_log_templates=_COMMON_OPS_LOG_TEMPLATES,
931
1112
  )
932
1113
 
933
1114
  @classmethod
@@ -948,6 +1129,7 @@ class GitHubEnterpriseServerDeploymentPlugin(_DeploymentBase):
948
1129
  'in their deploy workflow.'
949
1130
  ),
950
1131
  plugin_type='deployment',
1132
+ supports_deployment_sync=True,
951
1133
  options=[
952
1134
  PluginOption(
953
1135
  name='host',
@@ -959,6 +1141,7 @@ class GitHubEnterpriseServerDeploymentPlugin(_DeploymentBase):
959
1141
  ],
960
1142
  credentials=_COMMON_CREDENTIALS,
961
1143
  edge_labels=_COMMON_EDGE_LABELS,
1144
+ ops_log_templates=_COMMON_OPS_LOG_TEMPLATES,
962
1145
  )
963
1146
 
964
1147
  @classmethod
@@ -1,5 +1,6 @@
1
1
  """Smoke tests for the GitHub deployment plugins."""
2
2
 
3
+ import datetime
3
4
  import json
4
5
  import time
5
6
  import unittest
@@ -62,36 +63,30 @@ class ManifestTestCase(unittest.TestCase):
62
63
  self.assertIsInstance(cls(), DeploymentPlugin)
63
64
  self.assertEqual(cls.manifest.plugin_type, 'deployment')
64
65
 
65
- def test_all_declare_deploys_via_edge(self) -> None:
66
- # Every concrete subclass needs the DEPLOYS_VIA declaration so
67
- # admins can wire up per-env config from the plugin-edge UI for
68
- # any GitHub flavor (.com / GHEC / GHES).
66
+ def test_all_advertise_supports_deployment_sync(self) -> None:
69
67
  for cls in (
70
68
  GitHubDeploymentPlugin,
71
69
  GitHubEnterpriseCloudDeploymentPlugin,
72
70
  GitHubEnterpriseServerDeploymentPlugin,
73
71
  ):
74
- edge = next(
75
- (
76
- e
77
- for e in cls.manifest.edge_labels
78
- if e.name == 'DEPLOYS_VIA'
79
- ),
80
- None,
72
+ self.assertTrue(
73
+ cls.manifest.supports_deployment_sync,
74
+ f'{cls.__name__} must opt in to deployment sync',
81
75
  )
82
- self.assertIsNotNone(
83
- edge, f'{cls.__name__} missing DEPLOYS_VIA edge'
76
+
77
+ def test_no_legacy_deploys_via_edge_declared(self) -> None:
78
+ # Promote behaviour is inferred from the ref shape and per-env
79
+ # payloads ride on the USES_PLUGIN edge (``env_payloads``). No
80
+ # plugin should declare a leftover ``DEPLOYS_VIA`` edge.
81
+ for cls in (
82
+ GitHubDeploymentPlugin,
83
+ GitHubEnterpriseCloudDeploymentPlugin,
84
+ GitHubEnterpriseServerDeploymentPlugin,
85
+ ):
86
+ self.assertFalse(
87
+ any(e.name == 'DEPLOYS_VIA' for e in cls.manifest.edge_labels),
88
+ f'{cls.__name__} still declares DEPLOYS_VIA',
84
89
  )
85
- assert edge is not None
86
- self.assertEqual(set(edge.from_labels), {'ProjectType', 'Project'})
87
- self.assertEqual(edge.to_labels, ['Environment'])
88
- self.assertIn('action', edge.properties)
89
- self.assertEqual(edge.properties['payload'], 'dict[str, str]')
90
- self.assertIn('identity_plugin_id', edge.properties)
91
- # Properties from the prior workflow_dispatch design must
92
- # not be carried forward.
93
- self.assertNotIn('workflow', edge.properties)
94
- self.assertNotIn('inputs', edge.properties)
95
90
 
96
91
  def test_owner_repo_required(self) -> None:
97
92
  plugin = GitHubDeploymentPlugin()
@@ -648,9 +643,10 @@ class TriggerDeploymentTestCase(unittest.IsolatedAsyncioTestCase):
648
643
 
649
644
  @respx.mock
650
645
  async def test_trigger_uses_environment_config_payload(self) -> None:
651
- # The DEPLOYS_VIA edge supplies the deployment payload via
652
- # ``ctx.environment_config['payload']``. Caller-supplied
653
- # ``inputs`` are the base; env_config keys override them.
646
+ # ``ctx.environment_config`` carries the per-env payload dict
647
+ # (``env_payloads[env_slug]`` from the USES_PLUGIN edge,
648
+ # resolved by the host). Caller-supplied ``inputs`` layer on
649
+ # top, so a manual override wins on shared keys.
654
650
  deploy = respx.post(
655
651
  'https://api.github.com/repos/octo/demo/deployments'
656
652
  ).mock(return_value=httpx.Response(201, json={'id': 1, 'url': ''}))
@@ -658,43 +654,23 @@ class TriggerDeploymentTestCase(unittest.IsolatedAsyncioTestCase):
658
654
  ctx = _ctx(
659
655
  environment='production',
660
656
  environment_config={
661
- 'payload': {
662
- 'cluster': 'prod-east',
663
- 'feature_flag': 'on',
664
- }
657
+ 'cluster': 'prod-east',
658
+ 'feature_flag': 'on',
665
659
  },
666
660
  )
667
661
  await plugin.trigger_deployment(
668
662
  ctx,
669
663
  _CREDS,
670
664
  ref_or_sha='v1.2.3',
671
- inputs={'cluster': 'WILL-LOSE', 'extra': 'kept'},
665
+ inputs={'cluster': 'override', 'extra': 'kept'},
672
666
  )
673
667
  body = json.loads(deploy.calls.last.request.read())
674
668
  self.assertEqual(body['ref'], 'v1.2.3')
675
669
  self.assertEqual(body['environment'], 'production')
676
670
  self.assertEqual(
677
671
  body['payload'],
678
- {'cluster': 'prod-east', 'feature_flag': 'on', 'extra': 'kept'},
679
- )
680
-
681
- @respx.mock
682
- async def test_trigger_ignores_non_dict_env_payload(self) -> None:
683
- # A malformed edge property (string instead of dict) should not
684
- # crash the plugin — we drop it and fall back to caller inputs.
685
- deploy = respx.post(
686
- 'https://api.github.com/repos/octo/demo/deployments'
687
- ).mock(return_value=httpx.Response(201, json={'id': 2, 'url': ''}))
688
- plugin = GitHubDeploymentPlugin()
689
- ctx = _ctx(
690
- environment='staging',
691
- environment_config={'payload': 'not-a-dict'},
692
- )
693
- await plugin.trigger_deployment(
694
- ctx, _CREDS, ref_or_sha='v1.0.0', inputs={'k': 'v'}
672
+ {'cluster': 'override', 'feature_flag': 'on', 'extra': 'kept'},
695
673
  )
696
- body = json.loads(deploy.calls.last.request.read())
697
- self.assertEqual(body['payload'], {'k': 'v'})
698
674
 
699
675
 
700
676
  class ListRefsPaginationTestCase(unittest.IsolatedAsyncioTestCase):
@@ -860,6 +836,257 @@ class GetDeploymentStatusTestCase(unittest.IsolatedAsyncioTestCase):
860
836
  self.assertEqual(run.status, 'cancelled')
861
837
 
862
838
 
839
+ class ListRecentDeploymentsTestCase(unittest.IsolatedAsyncioTestCase):
840
+ @respx.mock
841
+ async def test_one_env_one_deployment_success(self) -> None:
842
+ respx.get(
843
+ 'https://api.github.com/repos/octo/demo/deployments',
844
+ params={
845
+ 'environment': 'infrastructure-testing',
846
+ 'per_page': '1',
847
+ },
848
+ ).mock(
849
+ return_value=httpx.Response(
850
+ 200,
851
+ json=[
852
+ {
853
+ 'id': 123,
854
+ 'sha': '2668cd0abc',
855
+ 'ref': 'main',
856
+ 'created_at': '2026-05-13T14:00:00Z',
857
+ 'description': 'Deploy main',
858
+ 'url': 'https://api.github.com/repos/octo/demo/deployments/123',
859
+ }
860
+ ],
861
+ )
862
+ )
863
+ respx.get(
864
+ 'https://api.github.com/repos/octo/demo/deployments/123/statuses'
865
+ ).mock(
866
+ return_value=httpx.Response(
867
+ 200,
868
+ json=[
869
+ {
870
+ 'state': 'success',
871
+ 'log_url': 'https://gh/runs/9001',
872
+ 'created_at': '2026-05-13T14:01:00Z',
873
+ }
874
+ ],
875
+ )
876
+ )
877
+ plugin = GitHubDeploymentPlugin()
878
+ events = await plugin.list_recent_deployments(
879
+ _ctx(), _CREDS, ['infrastructure-testing']
880
+ )
881
+ self.assertEqual(len(events), 1)
882
+ event = events[0]
883
+ self.assertEqual(event.environment, 'infrastructure-testing')
884
+ self.assertEqual(event.sha, '2668cd0abc')
885
+ self.assertEqual(event.ref, 'main')
886
+ self.assertEqual(event.status, 'success')
887
+ self.assertEqual(event.external_run_id, '123')
888
+ self.assertEqual(event.run_url, 'https://gh/runs/9001')
889
+ self.assertEqual(
890
+ event.deployment_url,
891
+ 'https://api.github.com/repos/octo/demo/deployments/123',
892
+ )
893
+ # ``created_at`` must come from the deployment row, not the
894
+ # latest status row (which is one minute later above).
895
+ self.assertEqual(
896
+ event.created_at,
897
+ datetime.datetime(2026, 5, 13, 14, 0, tzinfo=datetime.UTC),
898
+ )
899
+
900
+ @respx.mock
901
+ async def test_multiple_envs_fan_out_in_parallel(self) -> None:
902
+ respx.get(
903
+ 'https://api.github.com/repos/octo/demo/deployments',
904
+ params={'environment': 'production', 'per_page': '1'},
905
+ ).mock(
906
+ return_value=httpx.Response(
907
+ 200,
908
+ json=[
909
+ {
910
+ 'id': 1,
911
+ 'sha': 'prodsha',
912
+ 'ref': 'v1.0.0',
913
+ 'created_at': '2026-05-13T12:00:00Z',
914
+ }
915
+ ],
916
+ )
917
+ )
918
+ respx.get(
919
+ 'https://api.github.com/repos/octo/demo/deployments',
920
+ params={'environment': 'staging', 'per_page': '1'},
921
+ ).mock(
922
+ return_value=httpx.Response(
923
+ 200,
924
+ json=[
925
+ {
926
+ 'id': 2,
927
+ 'sha': 'stagesha',
928
+ 'ref': 'main',
929
+ 'created_at': '2026-05-13T13:00:00Z',
930
+ }
931
+ ],
932
+ )
933
+ )
934
+ # Both deployments resolve to ``pending`` because no statuses
935
+ # have been posted yet. The empty-statuses case must not be
936
+ # treated as an error.
937
+ respx.get(
938
+ 'https://api.github.com/repos/octo/demo/deployments/1/statuses'
939
+ ).mock(return_value=httpx.Response(200, json=[]))
940
+ respx.get(
941
+ 'https://api.github.com/repos/octo/demo/deployments/2/statuses'
942
+ ).mock(return_value=httpx.Response(200, json=[]))
943
+ plugin = GitHubDeploymentPlugin()
944
+ events = await plugin.list_recent_deployments(
945
+ _ctx(), _CREDS, ['production', 'staging']
946
+ )
947
+ by_env = {e.environment: e for e in events}
948
+ self.assertEqual(by_env['production'].external_run_id, '1')
949
+ self.assertEqual(by_env['staging'].external_run_id, '2')
950
+ self.assertEqual(by_env['production'].status, 'pending')
951
+ self.assertEqual(by_env['staging'].status, 'pending')
952
+
953
+ @respx.mock
954
+ async def test_unknown_env_skipped_not_raised(self) -> None:
955
+ # GitHub returns 404 for an environment the repo doesn't know
956
+ # about; resync must keep the partial result rather than fail.
957
+ respx.get(
958
+ 'https://api.github.com/repos/octo/demo/deployments',
959
+ params={'environment': 'production', 'per_page': '1'},
960
+ ).mock(
961
+ return_value=httpx.Response(
962
+ 200,
963
+ json=[
964
+ {
965
+ 'id': 7,
966
+ 'sha': 'abc',
967
+ 'created_at': '2026-05-13T14:00:00Z',
968
+ }
969
+ ],
970
+ )
971
+ )
972
+ respx.get(
973
+ 'https://api.github.com/repos/octo/demo/deployments',
974
+ params={'environment': 'never-deployed', 'per_page': '1'},
975
+ ).mock(return_value=httpx.Response(404, json={'message': 'Not Found'}))
976
+ respx.get(
977
+ 'https://api.github.com/repos/octo/demo/deployments/7/statuses'
978
+ ).mock(return_value=httpx.Response(200, json=[]))
979
+ plugin = GitHubDeploymentPlugin()
980
+ events = await plugin.list_recent_deployments(
981
+ _ctx(), _CREDS, ['production', 'never-deployed']
982
+ )
983
+ self.assertEqual([e.environment for e in events], ['production'])
984
+
985
+ @respx.mock
986
+ async def test_inactive_status_maps_to_rolled_back(self) -> None:
987
+ respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
988
+ return_value=httpx.Response(
989
+ 200,
990
+ json=[
991
+ {
992
+ 'id': 99,
993
+ 'sha': 'old',
994
+ 'created_at': '2026-05-01T00:00:00Z',
995
+ }
996
+ ],
997
+ )
998
+ )
999
+ respx.get(
1000
+ 'https://api.github.com/repos/octo/demo/deployments/99/statuses'
1001
+ ).mock(
1002
+ return_value=httpx.Response(
1003
+ 200,
1004
+ json=[{'state': 'inactive'}],
1005
+ )
1006
+ )
1007
+ plugin = GitHubDeploymentPlugin()
1008
+ events = await plugin.list_recent_deployments(
1009
+ _ctx(), _CREDS, ['staging']
1010
+ )
1011
+ self.assertEqual(events[0].status, 'rolled_back')
1012
+
1013
+ @respx.mock
1014
+ async def test_status_history_failure_maps_to_failed(self) -> None:
1015
+ respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
1016
+ return_value=httpx.Response(
1017
+ 200,
1018
+ json=[
1019
+ {
1020
+ 'id': 5,
1021
+ 'sha': 'abc',
1022
+ 'created_at': '2026-05-01T00:00:00Z',
1023
+ }
1024
+ ],
1025
+ )
1026
+ )
1027
+ respx.get(
1028
+ 'https://api.github.com/repos/octo/demo/deployments/5/statuses'
1029
+ ).mock(
1030
+ return_value=httpx.Response(
1031
+ 200,
1032
+ json=[{'state': 'failure'}, {'state': 'in_progress'}],
1033
+ )
1034
+ )
1035
+ plugin = GitHubDeploymentPlugin()
1036
+ events = await plugin.list_recent_deployments(
1037
+ _ctx(), _CREDS, ['staging']
1038
+ )
1039
+ self.assertEqual(events[0].status, 'failed')
1040
+
1041
+ @respx.mock
1042
+ async def test_status_fetch_error_degrades_to_pending(self) -> None:
1043
+ respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
1044
+ return_value=httpx.Response(
1045
+ 200,
1046
+ json=[
1047
+ {
1048
+ 'id': 8,
1049
+ 'sha': 'abc',
1050
+ 'created_at': '2026-05-01T00:00:00Z',
1051
+ }
1052
+ ],
1053
+ )
1054
+ )
1055
+ respx.get(
1056
+ 'https://api.github.com/repos/octo/demo/deployments/8/statuses'
1057
+ ).mock(return_value=httpx.Response(500, json={'message': 'oops'}))
1058
+ plugin = GitHubDeploymentPlugin()
1059
+ events = await plugin.list_recent_deployments(
1060
+ _ctx(), _CREDS, ['staging']
1061
+ )
1062
+ self.assertEqual(events[0].status, 'pending')
1063
+ self.assertIsNone(events[0].run_url)
1064
+
1065
+ @respx.mock
1066
+ async def test_deployment_missing_id_skipped(self) -> None:
1067
+ respx.get('https://api.github.com/repos/octo/demo/deployments').mock(
1068
+ return_value=httpx.Response(
1069
+ 200,
1070
+ json=[
1071
+ {'sha': 'abc'}, # missing id
1072
+ {
1073
+ 'id': 11,
1074
+ 'sha': 'def',
1075
+ 'created_at': '2026-05-13T00:00:00Z',
1076
+ },
1077
+ ],
1078
+ )
1079
+ )
1080
+ respx.get(
1081
+ 'https://api.github.com/repos/octo/demo/deployments/11/statuses'
1082
+ ).mock(return_value=httpx.Response(200, json=[]))
1083
+ plugin = GitHubDeploymentPlugin()
1084
+ events = await plugin.list_recent_deployments(
1085
+ _ctx(), _CREDS, ['staging']
1086
+ )
1087
+ self.assertEqual([e.external_run_id for e in events], ['11'])
1088
+
1089
+
863
1090
  class CheckStatusTestCase(unittest.IsolatedAsyncioTestCase):
864
1091
  @respx.mock
865
1092
  async def test_check_status_pass(self) -> None:
@@ -245,25 +245,33 @@ wheels = [
245
245
 
246
246
  [[package]]
247
247
  name = "clickhouse-connect"
248
- version = "0.12.0rc1"
248
+ version = "1.0.0"
249
249
  source = { registry = "https://pypi.org/simple" }
250
250
  dependencies = [
251
251
  { name = "certifi" },
252
252
  { name = "lz4" },
253
- { name = "pytz" },
253
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
254
254
  { name = "urllib3" },
255
255
  { name = "zstandard" },
256
256
  ]
257
- sdist = { url = "https://files.pythonhosted.org/packages/dc/45/f48c80d3a06fa14b3b0915c4b5dc6c4f065735b97a95c06d76f972f685e4/clickhouse_connect-0.12.0rc1.tar.gz", hash = "sha256:2411ee79c15625643633b3d1c8776ff596276d3abb952e317d7bdbc6e8d8a54e", size = 132165, upload-time = "2026-02-12T19:27:58.524Z" }
258
- wheels = [
259
- { url = "https://files.pythonhosted.org/packages/69/62/022f2962e3b81a9386edaa81de37ec27de45fd86b0340c19dd770a1f139b/clickhouse_connect-0.12.0rc1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e4f388a657f00fb396a3902153e3560193223a1f089dd7132ce5887b4c494112", size = 303670, upload-time = "2026-02-12T19:27:16.751Z" },
260
- { url = "https://files.pythonhosted.org/packages/65/6b/d221d923ccf33bd335fedf92ee386defdda86584490a3fb79fed6ff23400/clickhouse_connect-0.12.0rc1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b6fd7c4f3f951d3408181009d977c10df302c5a0571adcca147b296e22e292f2", size = 296187, upload-time = "2026-02-12T19:27:19.223Z" },
261
- { url = "https://files.pythonhosted.org/packages/af/70/3239bf69b7a98c68f20cb8697bb548ca5c0f48351c55e98e222ed111b3b2/clickhouse_connect-0.12.0rc1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0b3abb4616c1fcb7cd757797dcfa99e9b62eb70cb8b9e9127903d660947509b", size = 1133333, upload-time = "2026-02-12T19:27:20.658Z" },
262
- { url = "https://files.pythonhosted.org/packages/ea/a9/424e49b315f3defa31517807dae608621d8cd6bb4698c79a16b9a9578d90/clickhouse_connect-0.12.0rc1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9bdb801bd2c312319a7e1ce380982cfba6fd08d7d4ad2ce81c69984c78032e2", size = 1158195, upload-time = "2026-02-12T19:27:22.101Z" },
263
- { url = "https://files.pythonhosted.org/packages/81/cf/9edc5e79da5f36c39ce3b101372791708766c130d964e7ba1ba8f125d722/clickhouse_connect-0.12.0rc1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd9f73cb4673647082e331d1e3a0974286ebe9c3c200586ca71bf4d0e37ed56b", size = 1115736, upload-time = "2026-02-12T19:27:23.731Z" },
264
- { url = "https://files.pythonhosted.org/packages/95/81/902bfc46126e95f85943851684cb019d41dba862cec2ccd0d31cd9801fc0/clickhouse_connect-0.12.0rc1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abfbacd6bdb4f06c57447acb27c6a94f32dbb5887ccdc547504a72ddf79a3774", size = 1164557, upload-time = "2026-02-12T19:27:25.257Z" },
265
- { url = "https://files.pythonhosted.org/packages/f1/22/48f66a0e0cd7fa72dad8dec16132b4fbd14f87671e6e8f8e08ee502aeced/clickhouse_connect-0.12.0rc1-cp314-cp314-win32.whl", hash = "sha256:c211c9a792df958dd2ac74db3a2732d97822d97d9ae30ba0cf38161d44c12778", size = 276868, upload-time = "2026-02-12T19:27:26.572Z" },
266
- { url = "https://files.pythonhosted.org/packages/6f/f6/31f29883b397176f30435b973444c8b0cd6452e5955a30e20aff7b35b45e/clickhouse_connect-0.12.0rc1-cp314-cp314-win_amd64.whl", hash = "sha256:e6e2fb5618bb9c6c498fed431a12bcac12617b4c3b56c247836f7c5ce6f5b15d", size = 295970, upload-time = "2026-02-12T19:27:27.868Z" },
257
+ sdist = { url = "https://files.pythonhosted.org/packages/9c/65/4358da2a8b20af0f8b01895e420870ad969afc7b197be0c9af35811b4329/clickhouse_connect-1.0.0.tar.gz", hash = "sha256:0d4ed1e296c6e77dc0dbf486607cc39fc6e36866abb653543d5ad0a8426ea8f0", size = 140624, upload-time = "2026-05-14T14:47:08.669Z" }
258
+ wheels = [
259
+ { url = "https://files.pythonhosted.org/packages/ad/75/37f05df90fbb5f1bcc12be92be9e19e7d07a09ae23082fe4ade41f2459d1/clickhouse_connect-1.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:20b76e54c9ea77e86c47b3194f1a9ff1833bd6fa34cb81bfcec621eff94ed033", size = 315197, upload-time = "2026-05-14T14:46:38.385Z" },
260
+ { url = "https://files.pythonhosted.org/packages/4b/6a/8c99f92357e2901e7e6d322e64949ceb4355910522f7fb4e35c5e2861519/clickhouse_connect-1.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0fcccd2b49d57a09e465fc0c01c7c323f0db637241b3fc93d869d1b256c46184", size = 304704, upload-time = "2026-05-14T14:46:39.522Z" },
261
+ { url = "https://files.pythonhosted.org/packages/ed/f9/8830d8fc3100113263da1cf7a8e7d6de74a2f3770f0b26313d4ca4cc78b4/clickhouse_connect-1.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:295e6ff6e3f7fe71c28b1753967d8669ce12d924486150c74c892d8f67cba4d9", size = 1225573, upload-time = "2026-05-14T14:46:41.122Z" },
262
+ { url = "https://files.pythonhosted.org/packages/1d/39/3935cb0cd6858e29eb38d3bc49bf090501a164b44b9f71e3b4889dff363f/clickhouse_connect-1.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e74375b92cfa94cb4c944a393a027af5c5ed562fc6b862baf6288696bc48688", size = 1239054, upload-time = "2026-05-14T14:46:42.493Z" },
263
+ { url = "https://files.pythonhosted.org/packages/5d/6d/48b8666bb47b13d32b80298d20110111ca5974d6e4dab56ba83f22740319/clickhouse_connect-1.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dae66adb575b7eb9626d84edad4481d5c3d93ca680766c3f1891c13694913bee", size = 1205056, upload-time = "2026-05-14T14:46:44.052Z" },
264
+ { url = "https://files.pythonhosted.org/packages/76/48/2f2500fdbb33748c159c070084e1924c0d86b3c2efc1316f3f952e872eda/clickhouse_connect-1.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1415f440ae59b03e10bd5cc11c0034f30ad5f8fad74b42153926482c99ac21eb", size = 1237099, upload-time = "2026-05-14T14:46:45.387Z" },
265
+ { url = "https://files.pythonhosted.org/packages/3b/8a/03b4daf7a8b40a137a62972a57875793b50769af794a7c5f9da68d4c0234/clickhouse_connect-1.0.0-cp314-cp314-win32.whl", hash = "sha256:6977afeabca17cdd3bc11215902f20e850eb1d71451d93eaee8ac26068a84af4", size = 285043, upload-time = "2026-05-14T14:46:46.787Z" },
266
+ { url = "https://files.pythonhosted.org/packages/bb/3e/2c230465c033833554f741c18a3db9c97b3c91da147c9903b7ef7779b8d3/clickhouse_connect-1.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:29185891d5de03f60ff9da37dbe1e1e44bed8ef252d0d1281ddf3a450dfe5023", size = 304303, upload-time = "2026-05-14T14:46:47.97Z" },
267
+ { url = "https://files.pythonhosted.org/packages/7c/78/480f06e28c859cf9eef88e1297b936f78011258b8fd97d7dfe9307533fa1/clickhouse_connect-1.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3222ae6bf39930a34ff4a483c6b34a1f3dab02d6743428b0383f4d47af7a48db", size = 333380, upload-time = "2026-05-14T14:46:49.158Z" },
268
+ { url = "https://files.pythonhosted.org/packages/c3/7a/f79bde20e8fd4c620eccdcd4cff0ef68d68394e857d16e3788db324a6901/clickhouse_connect-1.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ec2a93d353415951ab10da943f30f0038acc0a1ff82b3253d82a106f3065f34", size = 325717, upload-time = "2026-05-14T14:46:50.715Z" },
269
+ { url = "https://files.pythonhosted.org/packages/87/ab/ac02a65cab5fb3d734218b9f4171de15ee7105fb180ea7ce7d2632ca6d4c/clickhouse_connect-1.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:320f0c53c4cb8b06732d875cabd35a357992a9e0fcf9f4a18b631ea7ce377ef1", size = 1316892, upload-time = "2026-05-14T14:46:51.905Z" },
270
+ { url = "https://files.pythonhosted.org/packages/20/8e/cb2538d6857d2906bd07f07ab0e1e4080f5ec47eab7fe42432ba476dc59a/clickhouse_connect-1.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ab4bda888575a2ea882ad4ed05bf9f5f15bb621209f6a4045231e3f26fedf7", size = 1297570, upload-time = "2026-05-14T14:46:53.673Z" },
271
+ { url = "https://files.pythonhosted.org/packages/8e/8f/10318294e345e4ad1ad7e467fff56677b1e18617227056aa9bbea7949c8f/clickhouse_connect-1.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c4f5c8fa36b605435b32efea4f40ce11babb5f5afcec1e00f6a6cce81f81000", size = 1273741, upload-time = "2026-05-14T14:46:55.424Z" },
272
+ { url = "https://files.pythonhosted.org/packages/cd/be/49c1acf41ef880f24ae69f2387ec5b7428496f97c4ce8863d1dfe824352b/clickhouse_connect-1.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:046f79215ebdd62192cc36e4d81b549570ca46e5b61e107b0d54cf7855b2ffff", size = 1288667, upload-time = "2026-05-14T14:46:56.948Z" },
273
+ { url = "https://files.pythonhosted.org/packages/21/21/c1aa0db7edc073b5aaa6aeae7f24ca3a1cad584516556c6bd5cccc3cf6fd/clickhouse_connect-1.0.0-cp314-cp314t-win32.whl", hash = "sha256:9c64f7a26f97eeca472dab6ca9baddc1e36fae1d1c244541926ada4fad14a99b", size = 309551, upload-time = "2026-05-14T14:46:58.449Z" },
274
+ { url = "https://files.pythonhosted.org/packages/3c/17/3d1f583075ef5e10cc45ba4c2b2f9533cc5fed45c5371aaf290df130bc80/clickhouse_connect-1.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:53f1f9cd31b1af371340f39923a08ba0aef57a7a85ef4a27376107f376c1efc3", size = 336112, upload-time = "2026-05-14T14:46:59.615Z" },
267
275
  ]
268
276
 
269
277
  [package.optional-dependencies]
@@ -644,10 +652,12 @@ wheels = [
644
652
 
645
653
  [[package]]
646
654
  name = "imbi-common"
647
- version = "0.11.0"
655
+ version = "2.5.5"
648
656
  source = { registry = "https://pypi.org/simple" }
649
657
  dependencies = [
650
658
  { name = "cryptography" },
659
+ { name = "httpx" },
660
+ { name = "jsonpointer" },
651
661
  { name = "jsonschema-models" },
652
662
  { name = "markdown-it-py" },
653
663
  { name = "nanoid" },
@@ -658,9 +668,9 @@ dependencies = [
658
668
  { name = "python-slugify" },
659
669
  { name = "typer" },
660
670
  ]
661
- sdist = { url = "https://files.pythonhosted.org/packages/f1/4b/9aa6bc182c45d4c912ee719a02df4b2f9b0bf137d9e86f620341adaa418d/imbi_common-0.11.0.tar.gz", hash = "sha256:7897c59b4e17f1a845f8dab8376fa6f887415571d4270d1bc903bcf8942eab09", size = 251346, upload-time = "2026-05-12T17:34:35.314Z" }
671
+ sdist = { url = "https://files.pythonhosted.org/packages/0d/b7/6dab7155b281f9c075d3434e3f234ef217fe49db840b14e59e3e5a75faa2/imbi_common-2.5.5.tar.gz", hash = "sha256:56751efe31fdc1fcb1d1be19e6b3f073d5b3e4fda3c89c0bcdec5c7943549477", size = 277481, upload-time = "2026-05-20T02:10:09.044Z" }
662
672
  wheels = [
663
- { url = "https://files.pythonhosted.org/packages/b1/c9/68aedc6fb10d13fd566d5ec3610e53f98511d760156e9e542c4855dde9f3/imbi_common-0.11.0-py3-none-any.whl", hash = "sha256:3871705aa7bf784dae376e7b456e0d177d5f463480ed99eda190be9f90e1e9e6", size = 75318, upload-time = "2026-05-12T17:34:33.791Z" },
673
+ { url = "https://files.pythonhosted.org/packages/48/04/69e72ec3a1968a9dd8b91df38e6a7be16e1399a054a953777a23f34c60ca/imbi_common-2.5.5-py3-none-any.whl", hash = "sha256:19ffa23d9cdb0b8065af1d3a9166f819c15414fc4b5707d089adaf64b932ebf1", size = 91154, upload-time = "2026-05-20T02:10:07.448Z" },
664
674
  ]
665
675
 
666
676
  [package.optional-dependencies]
@@ -678,7 +688,7 @@ server = [
678
688
 
679
689
  [[package]]
680
690
  name = "imbi-plugin-github"
681
- version = "1.1.0"
691
+ version = "1.2.0"
682
692
  source = { editable = "." }
683
693
  dependencies = [
684
694
  { name = "httpx" },
@@ -706,7 +716,7 @@ dist = [
706
716
  [package.metadata]
707
717
  requires-dist = [
708
718
  { name = "httpx", specifier = ">=0.27" },
709
- { name = "imbi-common", specifier = ">=0.11.0" },
719
+ { name = "imbi-common", specifier = ">=2.5.5" },
710
720
  { name = "pydantic", specifier = ">=2" },
711
721
  ]
712
722
 
@@ -778,6 +788,15 @@ wheels = [
778
788
  { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
779
789
  ]
780
790
 
791
+ [[package]]
792
+ name = "jsonpointer"
793
+ version = "3.1.1"
794
+ source = { registry = "https://pypi.org/simple" }
795
+ sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" }
796
+ wheels = [
797
+ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" },
798
+ ]
799
+
781
800
  [[package]]
782
801
  name = "jsonschema-models"
783
802
  version = "1.2.3"
@@ -1467,15 +1486,6 @@ wheels = [
1467
1486
  { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
1468
1487
  ]
1469
1488
 
1470
- [[package]]
1471
- name = "pytz"
1472
- version = "2026.2"
1473
- source = { registry = "https://pypi.org/simple" }
1474
- sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" }
1475
- wheels = [
1476
- { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" },
1477
- ]
1478
-
1479
1489
  [[package]]
1480
1490
  name = "pywin32-ctypes"
1481
1491
  version = "0.2.3"