imbi-plugin-github 2.21.0__tar.gz → 2.23.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_github-2.21.0 → imbi_plugin_github-2.23.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/pyproject.toml +2 -2
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/commits.py +399 -38
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/deployment.py +266 -14
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/plugin.py +38 -1
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_commits.py +486 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_deployment.py +389 -3
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/.gitignore +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/README.md +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/moon.yml +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/pull_requests.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_lifecycle.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_pull_requests.py +0 -0
- {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/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.
|
|
3
|
+
Version: 2.23.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-Expression: BSD-3-Clause
|
|
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.14
|
|
13
13
|
Requires-Python: >=3.14
|
|
14
14
|
Requires-Dist: httpx>=0.27
|
|
15
|
-
Requires-Dist: imbi-common[databases]==2.
|
|
15
|
+
Requires-Dist: imbi-common[databases]==2.23.0
|
|
16
16
|
Requires-Dist: pydantic>=2
|
|
17
17
|
Requires-Dist: pyjwt[crypto]>=2.8
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "imbi-plugin-github"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.23.0"
|
|
4
4
|
description = "GitHub identity plugin for Imbi (github.com / GHEC / GHES)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -18,7 +18,7 @@ classifiers = [
|
|
|
18
18
|
]
|
|
19
19
|
dependencies = [
|
|
20
20
|
"httpx>=0.27",
|
|
21
|
-
"imbi-common[databases]==2.
|
|
21
|
+
"imbi-common[databases]==2.23.0",
|
|
22
22
|
"pydantic>=2",
|
|
23
23
|
"pyjwt[crypto]>=2.8",
|
|
24
24
|
]
|
|
@@ -80,11 +80,20 @@ _MAX_COMPARE_PAGES = 20
|
|
|
80
80
|
# backfill so a very deep repo can't pin the worker indefinitely; the
|
|
81
81
|
# walk logs a truncation warning when it hits the cap.
|
|
82
82
|
_MAX_HISTORY_PAGES = 100
|
|
83
|
+
# Bound on the ``?since=`` walk that ``sync_new_commits`` falls back to
|
|
84
|
+
# when nothing is stored yet. Deliberately far smaller than
|
|
85
|
+
# ``_MAX_HISTORY_PAGES``: this path exists to pick up the handful of
|
|
86
|
+
# commits a release build pushed, not to stand in for a full backfill.
|
|
87
|
+
_MAX_SINCE_PAGES = 10
|
|
83
88
|
# CI status is hydrated with one ``/check-runs`` call per commit, which is
|
|
84
89
|
# prohibitively expensive across a full-history backfill. Bound it to the
|
|
85
90
|
# most-recent commits (the only ones whose CI is still meaningful and
|
|
86
91
|
# unexpired); older commits keep the ``'unknown'`` default.
|
|
87
92
|
_BACKFILL_CI_LIMIT = 25
|
|
93
|
+
# Page size for ``sync_new_commits``' last-resort walk, used when nothing
|
|
94
|
+
# is stored *and* no ``since`` was supplied. Sized to cover a release
|
|
95
|
+
# build's handful of pushed commits, not to approximate a backfill.
|
|
96
|
+
_RECENT_FALLBACK_COMMITS = 25
|
|
88
97
|
|
|
89
98
|
# Per-context ceilings on how long a *single* rate-limit pause may block
|
|
90
99
|
# before the sync gives up best-effort (logged). The webhook actions
|
|
@@ -713,6 +722,80 @@ async def _fetch_all_commits(
|
|
|
713
722
|
return out
|
|
714
723
|
|
|
715
724
|
|
|
725
|
+
async def _fetch_tag_ref(
|
|
726
|
+
client: httpx.AsyncClient, name: str, *, max_wait: float
|
|
727
|
+
) -> tuple[str, str] | None:
|
|
728
|
+
"""Resolve ``refs/tags/{name}`` to ``(sha, object_type)``.
|
|
729
|
+
|
|
730
|
+
``None`` when the ref does not exist (404) or can't be read. Unlike a
|
|
731
|
+
push payload, the ref response states the object's ``type``, so the
|
|
732
|
+
caller knows up front whether it must peel an annotated tag rather
|
|
733
|
+
than having to infer it from a probe.
|
|
734
|
+
|
|
735
|
+
Slashes stay unescaped: the ref path is hierarchical, so a tag named
|
|
736
|
+
``release/1.0`` addresses as ``tags/release/1.0``.
|
|
737
|
+
"""
|
|
738
|
+
quoted = urllib.parse.quote(name, safe='/')
|
|
739
|
+
resp = await _request(
|
|
740
|
+
client, 'GET', f'/git/ref/tags/{quoted}', max_wait=max_wait
|
|
741
|
+
)
|
|
742
|
+
if resp.status_code != 200:
|
|
743
|
+
return None
|
|
744
|
+
payload = typing.cast('dict[str, typing.Any]', resp.json())
|
|
745
|
+
obj: dict[str, typing.Any] = payload.get('object') or {}
|
|
746
|
+
sha = str(obj.get('sha') or '')
|
|
747
|
+
if not sha:
|
|
748
|
+
return None
|
|
749
|
+
# GitHub sends both fields together; defaulting a present sha to
|
|
750
|
+
# 'commit' matches ``_tag_record``'s handling of the same gap.
|
|
751
|
+
return sha, str(obj.get('type') or 'commit')
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
async def _fetch_commits_since(
|
|
755
|
+
client: httpx.AsyncClient,
|
|
756
|
+
branch: str,
|
|
757
|
+
since: datetime.datetime,
|
|
758
|
+
*,
|
|
759
|
+
max_wait: float,
|
|
760
|
+
) -> list[dict[str, typing.Any]]:
|
|
761
|
+
"""Walk ``GET /commits?since=`` from *branch*, newest first.
|
|
762
|
+
|
|
763
|
+
``since`` is a server-side filter on committer date -- the one time
|
|
764
|
+
bound GitHub's commit list actually offers -- so the walk costs pages
|
|
765
|
+
proportional to the window, not to the repo's history. Bounded by
|
|
766
|
+
:data:`_MAX_SINCE_PAGES` for the pathological case of a window wide
|
|
767
|
+
enough to cover thousands of commits.
|
|
768
|
+
"""
|
|
769
|
+
params: dict[str, str] = {
|
|
770
|
+
'sha': branch,
|
|
771
|
+
'since': since.isoformat(),
|
|
772
|
+
'per_page': '100',
|
|
773
|
+
}
|
|
774
|
+
out: list[dict[str, typing.Any]] = []
|
|
775
|
+
for page in range(1, _MAX_SINCE_PAGES + 1):
|
|
776
|
+
resp = await _request(
|
|
777
|
+
client, 'GET', '/commits', params=params, max_wait=max_wait
|
|
778
|
+
)
|
|
779
|
+
resp.raise_for_status()
|
|
780
|
+
out.extend(typing.cast('list[dict[str, typing.Any]]', resp.json()))
|
|
781
|
+
next_url = _next_page_url(resp.headers.get('link'))
|
|
782
|
+
if next_url is None:
|
|
783
|
+
return out
|
|
784
|
+
next_page = _query_param(next_url, 'page')
|
|
785
|
+
if next_page is None:
|
|
786
|
+
return out
|
|
787
|
+
params['page'] = next_page
|
|
788
|
+
if page == _MAX_SINCE_PAGES:
|
|
789
|
+
LOGGER.warning(
|
|
790
|
+
'github-commit-sync truncated the since-window walk at %d '
|
|
791
|
+
'pages (%d commits); widen the stored history with a full '
|
|
792
|
+
'sync instead',
|
|
793
|
+
_MAX_SINCE_PAGES,
|
|
794
|
+
len(out),
|
|
795
|
+
)
|
|
796
|
+
return out
|
|
797
|
+
|
|
798
|
+
|
|
716
799
|
async def _insert_best_effort(
|
|
717
800
|
table: str, records: list[pydantic.BaseModel], project_id: str
|
|
718
801
|
) -> int:
|
|
@@ -887,12 +970,30 @@ async def sync_commits(
|
|
|
887
970
|
|
|
888
971
|
async def _annotated_tag(
|
|
889
972
|
client: httpx.AsyncClient, sha: str, *, max_wait: float
|
|
890
|
-
) -> dict[str, typing.Any] | None:
|
|
891
|
-
"""Fetch annotated-tag metadata
|
|
973
|
+
) -> tuple[dict[str, typing.Any] | None, bool]:
|
|
974
|
+
"""Fetch annotated-tag metadata as ``(payload, conclusive)``.
|
|
975
|
+
|
|
976
|
+
Three outcomes, which callers must keep apart:
|
|
977
|
+
|
|
978
|
+
* ``(payload, True)`` -- an annotated tag; *payload* carries the
|
|
979
|
+
tagger/message and the ``object`` to peel.
|
|
980
|
+
* ``(None, True)`` -- a 404: *sha* is not a tag object, so the ref
|
|
981
|
+
points straight at a commit (a lightweight tag).
|
|
982
|
+
* ``(None, False)`` -- any other failure (5xx, permissions). We
|
|
983
|
+
cannot tell which of the two it was.
|
|
984
|
+
|
|
985
|
+
Collapsing the last case into the lightweight one would record the
|
|
986
|
+
ref target as if it were a commit, which for an annotated tag is the
|
|
987
|
+
tag object hash -- the unjoinable value :func:`_tag_record` exists to
|
|
988
|
+
keep out. Callers skip the tag instead; a later push or backfill
|
|
989
|
+
re-syncs it (``ReplacingMergeTree`` dedupes).
|
|
990
|
+
"""
|
|
892
991
|
resp = await _request(client, 'GET', f'/git/tags/{sha}', max_wait=max_wait)
|
|
992
|
+
if resp.status_code == 404:
|
|
993
|
+
return None, True
|
|
893
994
|
if resp.status_code != 200:
|
|
894
|
-
return None
|
|
895
|
-
return typing.cast('dict[str, typing.Any]', resp.json())
|
|
995
|
+
return None, False
|
|
996
|
+
return typing.cast('dict[str, typing.Any]', resp.json()), True
|
|
896
997
|
|
|
897
998
|
|
|
898
999
|
async def _commit_date(
|
|
@@ -995,8 +1096,26 @@ def _tag_record(
|
|
|
995
1096
|
url: str = '',
|
|
996
1097
|
published_at: datetime.datetime | None = None,
|
|
997
1098
|
fallback_tagged_at: datetime.datetime | None = None,
|
|
998
|
-
) -> TagRecord:
|
|
999
|
-
"""Build the ClickHouse row for one tag.
|
|
1099
|
+
) -> TagRecord | None:
|
|
1100
|
+
"""Build the ClickHouse row for one tag, ``None`` to skip it.
|
|
1101
|
+
|
|
1102
|
+
``sha`` records the *commit* the tag resolves to, never the hash of an
|
|
1103
|
+
annotated tag object. Callers pass whatever the ref points at, which
|
|
1104
|
+
for an annotated tag is the tag object; the peeled commit is read back
|
|
1105
|
+
off *annotated* (``object.sha``). Storing the tag object hash instead
|
|
1106
|
+
breaks every downstream consumer, all of which key on commit SHAs --
|
|
1107
|
+
the ``commits`` join that supplies a release's CI status and
|
|
1108
|
+
authorship, and the Deployments tab's release-to-commit matching.
|
|
1109
|
+
|
|
1110
|
+
An annotated tag that cannot be peeled to a commit is *skipped*
|
|
1111
|
+
rather than recorded against the tag object: a row whose sha matches
|
|
1112
|
+
no commit renders a release with unknown CI, no author, and an empty
|
|
1113
|
+
commit list, which is the failure this peeling exists to prevent.
|
|
1114
|
+
Skipping keeps the bad value out of ClickHouse and leaves a warning
|
|
1115
|
+
to diagnose from. GitHub always returns ``object`` on a tag object, so
|
|
1116
|
+
this is defensive -- a tag pointing at a tree/blob, or a nested
|
|
1117
|
+
annotated tag (which is legal git but never a release, and is not
|
|
1118
|
+
peeled recursively), takes the same path.
|
|
1000
1119
|
|
|
1001
1120
|
``tagged_at`` preference: the GitHub release's published date
|
|
1002
1121
|
(*published_at*), then the annotated tag's tagger date, then the
|
|
@@ -1012,10 +1131,26 @@ def _tag_record(
|
|
|
1012
1131
|
tagged_at=published_at or fallback_tagged_at,
|
|
1013
1132
|
)
|
|
1014
1133
|
tagger: dict[str, typing.Any] = annotated.get('tagger') or {}
|
|
1134
|
+
target: dict[str, typing.Any] = annotated.get('object') or {}
|
|
1135
|
+
commit_sha = str(target.get('sha') or '')
|
|
1136
|
+
# A payload carrying a sha but no type is trusted: GitHub sends both,
|
|
1137
|
+
# and defaulting to 'commit' keeps a well-formed row from being
|
|
1138
|
+
# dropped over a missing field.
|
|
1139
|
+
target_type = str(target.get('type') or 'commit')
|
|
1140
|
+
if not commit_sha or target_type != 'commit':
|
|
1141
|
+
LOGGER.warning(
|
|
1142
|
+
'github-commit-sync: skipping tag %s for project %s; its tag '
|
|
1143
|
+
'object %s resolves to %s, not a commit',
|
|
1144
|
+
name,
|
|
1145
|
+
project_id,
|
|
1146
|
+
sha,
|
|
1147
|
+
f'{target_type} {commit_sha}' if commit_sha else 'nothing',
|
|
1148
|
+
)
|
|
1149
|
+
return None
|
|
1015
1150
|
return TagRecord(
|
|
1016
1151
|
project_id=project_id,
|
|
1017
1152
|
name=name,
|
|
1018
|
-
sha=
|
|
1153
|
+
sha=commit_sha,
|
|
1019
1154
|
url=url,
|
|
1020
1155
|
message=str(annotated.get('message') or ''),
|
|
1021
1156
|
tagger_name=str(tagger.get('name') or ''),
|
|
@@ -1032,6 +1167,8 @@ async def _reconcile_tags(
|
|
|
1032
1167
|
``/git/matching-refs/tags`` yields each tag's object sha + type;
|
|
1033
1168
|
annotated tags (``type == 'tag'``) are enriched with tagger/message/
|
|
1034
1169
|
date from the tag object, lightweight tags carry name/sha/url only.
|
|
1170
|
+
An annotated tag's ref points at the tag object rather than a commit,
|
|
1171
|
+
so ``_tag_record`` peels it back to the commit before recording it.
|
|
1035
1172
|
``ReplacingMergeTree`` dedupes against rows recorded from pushes.
|
|
1036
1173
|
"""
|
|
1037
1174
|
out: list[TagRecord] = []
|
|
@@ -1061,26 +1198,40 @@ async def _reconcile_tags(
|
|
|
1061
1198
|
client, max_wait=max_wait
|
|
1062
1199
|
)
|
|
1063
1200
|
published = released.get(name)
|
|
1064
|
-
annotated
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
name=name,
|
|
1073
|
-
sha=sha,
|
|
1074
|
-
annotated=annotated,
|
|
1075
|
-
url=_tag_web_url(client, name),
|
|
1076
|
-
published_at=published,
|
|
1077
|
-
fallback_tagged_at=(
|
|
1078
|
-
await _commit_date(client, sha, max_wait=max_wait)
|
|
1079
|
-
if annotated is None and published is None
|
|
1080
|
-
else None
|
|
1081
|
-
),
|
|
1201
|
+
# The ref tells us whether this is an annotated tag, so an
|
|
1202
|
+
# inconclusive lookup is not a lightweight tag -- it is a tag
|
|
1203
|
+
# we cannot peel. Skip it rather than recording the ref target
|
|
1204
|
+
# (the tag object) as if it were the commit.
|
|
1205
|
+
annotated: dict[str, typing.Any] | None = None
|
|
1206
|
+
if obj.get('type') == 'tag':
|
|
1207
|
+
annotated, conclusive = await _annotated_tag(
|
|
1208
|
+
client, sha, max_wait=max_wait
|
|
1082
1209
|
)
|
|
1210
|
+
if annotated is None:
|
|
1211
|
+
LOGGER.warning(
|
|
1212
|
+
'github-commit-sync: skipping tag %s for project '
|
|
1213
|
+
'%s; its tag object %s could not be %s',
|
|
1214
|
+
name,
|
|
1215
|
+
project_id,
|
|
1216
|
+
sha,
|
|
1217
|
+
'read' if conclusive else 'fetched',
|
|
1218
|
+
)
|
|
1219
|
+
continue
|
|
1220
|
+
record = _tag_record(
|
|
1221
|
+
project_id=project_id,
|
|
1222
|
+
name=name,
|
|
1223
|
+
sha=sha,
|
|
1224
|
+
annotated=annotated,
|
|
1225
|
+
url=_tag_web_url(client, name),
|
|
1226
|
+
published_at=published,
|
|
1227
|
+
fallback_tagged_at=(
|
|
1228
|
+
await _commit_date(client, sha, max_wait=max_wait)
|
|
1229
|
+
if annotated is None and published is None
|
|
1230
|
+
else None
|
|
1231
|
+
),
|
|
1083
1232
|
)
|
|
1233
|
+
if record is not None:
|
|
1234
|
+
out.append(record)
|
|
1084
1235
|
next_url = _next_page_url(resp.headers.get('link'))
|
|
1085
1236
|
if next_url is None:
|
|
1086
1237
|
return out
|
|
@@ -1120,14 +1271,19 @@ async def sync_tags(
|
|
|
1120
1271
|
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
1121
1272
|
try:
|
|
1122
1273
|
async with _client(base, owner, repo, token) as client:
|
|
1123
|
-
annotated = await _annotated_tag(
|
|
1274
|
+
annotated, conclusive = await _annotated_tag(
|
|
1124
1275
|
client, after, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
1125
1276
|
)
|
|
1126
1277
|
published = await _release_published_for_tag(
|
|
1127
1278
|
client, name, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
1128
1279
|
)
|
|
1129
|
-
|
|
1130
|
-
|
|
1280
|
+
# A push payload carries no object type, so only a conclusive
|
|
1281
|
+
# 404 proves ``after`` is a commit. An inconclusive lookup may
|
|
1282
|
+
# be an annotated tag, whose ref target is the tag object --
|
|
1283
|
+
# skip rather than record it as the commit.
|
|
1284
|
+
pushed: TagRecord | None = None
|
|
1285
|
+
if conclusive:
|
|
1286
|
+
pushed = _tag_record(
|
|
1131
1287
|
project_id=ctx.project_id,
|
|
1132
1288
|
name=name,
|
|
1133
1289
|
sha=after,
|
|
@@ -1142,12 +1298,25 @@ async def sync_tags(
|
|
|
1142
1298
|
else None
|
|
1143
1299
|
),
|
|
1144
1300
|
)
|
|
1145
|
-
|
|
1301
|
+
else:
|
|
1302
|
+
LOGGER.warning(
|
|
1303
|
+
'github-commit-sync: skipping tag %s for project %s; '
|
|
1304
|
+
'could not determine whether %s is a tag object',
|
|
1305
|
+
name,
|
|
1306
|
+
ctx.project_id,
|
|
1307
|
+
after,
|
|
1308
|
+
)
|
|
1309
|
+
records: list[pydantic.BaseModel] = (
|
|
1310
|
+
[] if pushed is None else [pushed]
|
|
1311
|
+
)
|
|
1146
1312
|
if action_config.reconcile_all:
|
|
1147
1313
|
extra = await _reconcile_tags(
|
|
1148
1314
|
client, ctx.project_id, max_wait=_WEBHOOK_MAX_WAIT_SECONDS
|
|
1149
1315
|
)
|
|
1150
|
-
|
|
1316
|
+
# Only reserve the pushed name when it actually produced a
|
|
1317
|
+
# row: when it was skipped, the reconcile pass may have
|
|
1318
|
+
# resolved the same tag and that result should stand.
|
|
1319
|
+
seen = {name} if pushed is not None else set[str]()
|
|
1151
1320
|
records.extend(r for r in extra if r.name not in seen)
|
|
1152
1321
|
except PluginRateLimited as exc:
|
|
1153
1322
|
LOGGER.warning(
|
|
@@ -1157,6 +1326,8 @@ async def sync_tags(
|
|
|
1157
1326
|
exc,
|
|
1158
1327
|
)
|
|
1159
1328
|
return
|
|
1329
|
+
if not records:
|
|
1330
|
+
return # every tag in this delivery was unpeelable (warned above)
|
|
1160
1331
|
try:
|
|
1161
1332
|
await clickhouse.insert('tags', records)
|
|
1162
1333
|
except Exception:
|
|
@@ -1261,14 +1432,7 @@ class GitHubCommitSync(CommitSyncCapability):
|
|
|
1261
1432
|
``_BACKFILL_MAX_WAIT_SECONDS`` so the host can pause the worker and
|
|
1262
1433
|
keep the job queued until GitHub resumes rather than fail it.
|
|
1263
1434
|
"""
|
|
1264
|
-
|
|
1265
|
-
if host is None:
|
|
1266
|
-
raise ValueError(
|
|
1267
|
-
'github-commit-sync could not resolve a GitHub host for an '
|
|
1268
|
-
'on-demand sync: set the Integration flavor/host'
|
|
1269
|
-
)
|
|
1270
|
-
base = host_to_api_base(host)
|
|
1271
|
-
owner, repo = resolve_owner_repo(ctx, host, 'github-commit-sync')
|
|
1435
|
+
base, owner, repo = self._resolve_target(ctx)
|
|
1272
1436
|
pushed_at = datetime.datetime.now(datetime.UTC)
|
|
1273
1437
|
try:
|
|
1274
1438
|
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
@@ -1327,3 +1491,200 @@ class GitHubCommitSync(CommitSyncCapability):
|
|
|
1327
1491
|
'tags', list(tags), ctx.project_id
|
|
1328
1492
|
)
|
|
1329
1493
|
return commits_recorded, tags_recorded
|
|
1494
|
+
|
|
1495
|
+
def _resolve_target(self, ctx: PluginContext) -> tuple[str, str, str]:
|
|
1496
|
+
"""Return ``(api_base, owner, repo)`` for a host-invoked sync."""
|
|
1497
|
+
host = _resolve_host_for_context(ctx)
|
|
1498
|
+
if host is None:
|
|
1499
|
+
raise ValueError(
|
|
1500
|
+
'github-commit-sync could not resolve a GitHub host for an '
|
|
1501
|
+
'on-demand sync: set the Integration flavor/host'
|
|
1502
|
+
)
|
|
1503
|
+
owner, repo = resolve_owner_repo(ctx, host, 'github-commit-sync')
|
|
1504
|
+
return host_to_api_base(host), owner, repo
|
|
1505
|
+
|
|
1506
|
+
async def sync_tag(
|
|
1507
|
+
self,
|
|
1508
|
+
*,
|
|
1509
|
+
ctx: PluginContext,
|
|
1510
|
+
credentials: dict[str, str],
|
|
1511
|
+
tag: str,
|
|
1512
|
+
) -> int:
|
|
1513
|
+
"""Record the one tag named *tag* -- three calls, not a resync.
|
|
1514
|
+
|
|
1515
|
+
Resolves ``refs/tags/{tag}`` directly instead of walking the
|
|
1516
|
+
repo's tag list, so cost is flat in the number of tags the repo
|
|
1517
|
+
already carries. That matters because the caller is a per-release
|
|
1518
|
+
hook: ``_reconcile_tags`` re-peels every existing tag on every
|
|
1519
|
+
run, which on a long-lived repo is hundreds of calls to learn one
|
|
1520
|
+
new row.
|
|
1521
|
+
|
|
1522
|
+
Returns ``0`` (logged) rather than raising when the ref is absent
|
|
1523
|
+
or an annotated tag can't be peeled -- a build that failed before
|
|
1524
|
+
tagging, or a transient read failure, must not fail the caller.
|
|
1525
|
+
"""
|
|
1526
|
+
base, owner, repo = self._resolve_target(ctx)
|
|
1527
|
+
try:
|
|
1528
|
+
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
1529
|
+
except _app_auth.AppNotInstalledError as exc:
|
|
1530
|
+
LOGGER.warning(
|
|
1531
|
+
'github-commit-sync: %s; skipping tag sync for project %s',
|
|
1532
|
+
exc,
|
|
1533
|
+
ctx.project_id,
|
|
1534
|
+
)
|
|
1535
|
+
return 0
|
|
1536
|
+
async with _client(base, owner, repo, token) as client:
|
|
1537
|
+
resolved = await _fetch_tag_ref(
|
|
1538
|
+
client, tag, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1539
|
+
)
|
|
1540
|
+
if resolved is None:
|
|
1541
|
+
LOGGER.warning(
|
|
1542
|
+
'github-commit-sync: tag %s does not resolve on the '
|
|
1543
|
+
'remote for project %s; nothing recorded',
|
|
1544
|
+
tag,
|
|
1545
|
+
ctx.project_id,
|
|
1546
|
+
)
|
|
1547
|
+
return 0
|
|
1548
|
+
sha, object_type = resolved
|
|
1549
|
+
# The ref states the object type, so an annotated tag is known
|
|
1550
|
+
# up front -- no need for the push path's inconclusive probe.
|
|
1551
|
+
annotated: dict[str, typing.Any] | None = None
|
|
1552
|
+
if object_type == 'tag':
|
|
1553
|
+
annotated, conclusive = await _annotated_tag(
|
|
1554
|
+
client, sha, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1555
|
+
)
|
|
1556
|
+
if annotated is None:
|
|
1557
|
+
LOGGER.warning(
|
|
1558
|
+
'github-commit-sync: skipping tag %s for project '
|
|
1559
|
+
'%s; its tag object %s could not be %s',
|
|
1560
|
+
tag,
|
|
1561
|
+
ctx.project_id,
|
|
1562
|
+
sha,
|
|
1563
|
+
'read' if conclusive else 'fetched',
|
|
1564
|
+
)
|
|
1565
|
+
return 0
|
|
1566
|
+
published = await _release_published_for_tag(
|
|
1567
|
+
client, tag, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1568
|
+
)
|
|
1569
|
+
record = _tag_record(
|
|
1570
|
+
project_id=ctx.project_id,
|
|
1571
|
+
name=tag,
|
|
1572
|
+
sha=sha,
|
|
1573
|
+
annotated=annotated,
|
|
1574
|
+
url=_tag_web_url(client, tag),
|
|
1575
|
+
published_at=published,
|
|
1576
|
+
fallback_tagged_at=(
|
|
1577
|
+
await _commit_date(
|
|
1578
|
+
client, sha, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1579
|
+
)
|
|
1580
|
+
if annotated is None and published is None
|
|
1581
|
+
else None
|
|
1582
|
+
),
|
|
1583
|
+
)
|
|
1584
|
+
if record is None:
|
|
1585
|
+
return 0 # unpeelable (warned by _tag_record)
|
|
1586
|
+
return await _insert_best_effort('tags', [record], ctx.project_id)
|
|
1587
|
+
|
|
1588
|
+
async def sync_new_commits(
|
|
1589
|
+
self,
|
|
1590
|
+
*,
|
|
1591
|
+
ctx: PluginContext,
|
|
1592
|
+
credentials: dict[str, str],
|
|
1593
|
+
since: datetime.datetime | None = None,
|
|
1594
|
+
) -> int:
|
|
1595
|
+
"""Record default-branch commits newer than what we already hold.
|
|
1596
|
+
|
|
1597
|
+
Bounded by the newest stored commit: ``GET /compare/{stored}...
|
|
1598
|
+
{branch}`` returns only what arrived since, which for a release
|
|
1599
|
+
build's version bump is a page at most. Falls back to a
|
|
1600
|
+
``?since=`` window when nothing is stored yet (or when the stored
|
|
1601
|
+
sha is no longer comparable -- a force-push or a rewritten
|
|
1602
|
+
branch), and to a small recent-history page when neither applies.
|
|
1603
|
+
"""
|
|
1604
|
+
base, owner, repo = self._resolve_target(ctx)
|
|
1605
|
+
pushed_at = datetime.datetime.now(datetime.UTC)
|
|
1606
|
+
try:
|
|
1607
|
+
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
1608
|
+
except _app_auth.AppNotInstalledError as exc:
|
|
1609
|
+
LOGGER.warning(
|
|
1610
|
+
'github-commit-sync: %s; skipping commit sync for project %s',
|
|
1611
|
+
exc,
|
|
1612
|
+
ctx.project_id,
|
|
1613
|
+
)
|
|
1614
|
+
return 0
|
|
1615
|
+
async with _client(base, owner, repo, token) as client:
|
|
1616
|
+
branch = await _fetch_default_branch(
|
|
1617
|
+
client, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1618
|
+
)
|
|
1619
|
+
raw = await self._fetch_new_commits(
|
|
1620
|
+
client, ctx.project_id, branch, since
|
|
1621
|
+
)
|
|
1622
|
+
ci_by_sha = await _hydrate_ci(
|
|
1623
|
+
client,
|
|
1624
|
+
[
|
|
1625
|
+
str(i['sha'])
|
|
1626
|
+
for i in raw[:_BACKFILL_CI_LIMIT]
|
|
1627
|
+
if i.get('sha')
|
|
1628
|
+
],
|
|
1629
|
+
credentials=credentials,
|
|
1630
|
+
base=base,
|
|
1631
|
+
owner=owner,
|
|
1632
|
+
repo=repo,
|
|
1633
|
+
max_wait=_BACKFILL_MAX_WAIT_SECONDS,
|
|
1634
|
+
)
|
|
1635
|
+
user_map = await _resolve_author_users(
|
|
1636
|
+
raw, ctx.resolve_user_by_identity, base
|
|
1637
|
+
)
|
|
1638
|
+
records: list[pydantic.BaseModel] = [
|
|
1639
|
+
_commit_record(
|
|
1640
|
+
item,
|
|
1641
|
+
project_id=ctx.project_id,
|
|
1642
|
+
ref=branch,
|
|
1643
|
+
pushed_at=pushed_at,
|
|
1644
|
+
author_user=_author_user(item, user_map),
|
|
1645
|
+
ci_status=ci_by_sha.get(str(item['sha']), 'unknown'),
|
|
1646
|
+
)
|
|
1647
|
+
for item in raw
|
|
1648
|
+
if item.get('sha')
|
|
1649
|
+
]
|
|
1650
|
+
return await _insert_best_effort('commits', records, ctx.project_id)
|
|
1651
|
+
|
|
1652
|
+
async def _fetch_new_commits(
|
|
1653
|
+
self,
|
|
1654
|
+
client: httpx.AsyncClient,
|
|
1655
|
+
project_id: str,
|
|
1656
|
+
branch: str,
|
|
1657
|
+
since: datetime.datetime | None,
|
|
1658
|
+
) -> list[dict[str, typing.Any]]:
|
|
1659
|
+
"""Pick the cheapest walk that covers what we don't already hold."""
|
|
1660
|
+
last = await _last_known_sha(project_id)
|
|
1661
|
+
if last:
|
|
1662
|
+
try:
|
|
1663
|
+
return await _fetch_compare_commits(
|
|
1664
|
+
client, last, branch, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1665
|
+
)
|
|
1666
|
+
except httpx.HTTPStatusError as exc:
|
|
1667
|
+
# A stored sha that no longer shares history with the
|
|
1668
|
+
# branch (force-push, rewritten history) compares 404 /
|
|
1669
|
+
# 422. Fall through rather than fail: the window walk
|
|
1670
|
+
# below still records what the caller was after.
|
|
1671
|
+
if exc.response.status_code not in {404, 422}:
|
|
1672
|
+
raise
|
|
1673
|
+
LOGGER.warning(
|
|
1674
|
+
'github-commit-sync: %s...%s is not comparable for '
|
|
1675
|
+
'project %s (HTTP %s); falling back to a bounded walk',
|
|
1676
|
+
last,
|
|
1677
|
+
branch,
|
|
1678
|
+
project_id,
|
|
1679
|
+
exc.response.status_code,
|
|
1680
|
+
)
|
|
1681
|
+
if since is not None:
|
|
1682
|
+
return await _fetch_commits_since(
|
|
1683
|
+
client, branch, since, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
1684
|
+
)
|
|
1685
|
+
return await _fetch_recent_commits(
|
|
1686
|
+
client,
|
|
1687
|
+
branch,
|
|
1688
|
+
_RECENT_FALLBACK_COMMITS,
|
|
1689
|
+
max_wait=_BACKFILL_MAX_WAIT_SECONDS,
|
|
1690
|
+
)
|