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.
Files changed (27) hide show
  1. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/commits.py +399 -38
  4. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/deployment.py +266 -14
  5. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/plugin.py +38 -1
  6. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_commits.py +486 -0
  7. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_deployment.py +389 -3
  8. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/.gitignore +0 -0
  9. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/README.md +0 -0
  10. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/moon.yml +0 -0
  11. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/README.md +0 -0
  12. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/__init__.py +0 -0
  13. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/_app_auth.py +0 -0
  14. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/_hosts.py +0 -0
  15. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/_repos.py +0 -0
  16. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/doctor.py +0 -0
  17. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/identity.py +0 -0
  18. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  19. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/pull_requests.py +0 -0
  20. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/src/imbi/plugins/github/py.typed +0 -0
  21. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/__init__.py +0 -0
  22. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_doctor.py +0 -0
  23. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_hosts.py +0 -0
  24. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_identity.py +0 -0
  25. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_lifecycle.py +0 -0
  26. {imbi_plugin_github-2.21.0 → imbi_plugin_github-2.23.0}/tests/test_pull_requests.py +0 -0
  27. {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.21.0
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.21.0
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.21.0"
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.0",
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, ``None`` for a lightweight tag."""
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=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
- await _annotated_tag(client, sha, max_wait=max_wait)
1066
- if obj.get('type') == 'tag'
1067
- else None
1068
- )
1069
- out.append(
1070
- _tag_record(
1071
- project_id=project_id,
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
- records: list[pydantic.BaseModel] = [
1130
- _tag_record(
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
- seen = {name}
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
- host = _resolve_host_for_context(ctx)
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
+ )