imbi-plugin-github 2.32.3__tar.gz → 2.33.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 (28) hide show
  1. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/PKG-INFO +2 -2
  2. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/pyproject.toml +2 -2
  3. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/commits.py +61 -3
  4. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/deployment.py +36 -6
  5. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/pull_requests.py +4 -1
  6. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_commits.py +185 -2
  7. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_deployment.py +93 -0
  8. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_pull_requests.py +25 -0
  9. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/.gitignore +0 -0
  10. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/README.md +0 -0
  11. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/moon.yml +0 -0
  12. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/README.md +0 -0
  13. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/__init__.py +0 -0
  14. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/_app_auth.py +0 -0
  15. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/_hosts.py +0 -0
  16. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/_repos.py +0 -0
  17. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/doctor.py +0 -0
  18. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/identity.py +0 -0
  19. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/lifecycle.py +0 -0
  20. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/plugin.py +0 -0
  21. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/py.typed +0 -0
  22. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/__init__.py +0 -0
  23. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_app_auth_scopes.py +0 -0
  24. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_doctor.py +0 -0
  25. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_hosts.py +0 -0
  26. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_identity.py +0 -0
  27. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_lifecycle.py +0 -0
  28. {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_repos.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: imbi-plugin-github
3
- Version: 2.32.3
3
+ Version: 2.33.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.32.3
15
+ Requires-Dist: imbi-common[databases]==2.33.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.32.3"
3
+ version = "2.33.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.32.3",
21
+ "imbi-common[databases]==2.33.0",
22
22
  "pydantic>=2",
23
23
  "pyjwt[crypto]>=2.8",
24
24
  ]
@@ -570,6 +570,50 @@ async def _last_known_sha(project_id: str) -> str | None:
570
570
  return None
571
571
 
572
572
 
573
+ async def _stored_pushed_at(
574
+ project_id: str, shas: collections.abc.Iterable[str]
575
+ ) -> dict[str, datetime.datetime]:
576
+ """``pushed_at`` already recorded for each of *shas*, keyed by sha.
577
+
578
+ A re-sync must carry the stored push time forward rather than stamp
579
+ the commit with the current clock. ``pushed_at`` is what orders the
580
+ recent-commits feed and picks :func:`_last_known_sha`, and the
581
+ ``commits`` table is a ``ReplacingMergeTree`` versioned on
582
+ ``recorded_at`` -- so a row re-inserted with a fresh ``pushed_at``
583
+ (a ``workflow_run`` delivery re-syncing a commit stored days earlier)
584
+ replaces the original and moves the commit out of push order.
585
+
586
+ Reads the *earliest* ``pushed_at`` still on disk for each sha, which
587
+ is the closest thing to the real push time when an earlier re-sync has
588
+ already re-stamped the row and the parts have not merged yet.
589
+ Best-effort: a lookup failure answers ``{}`` and the caller stamps
590
+ every row with the current time, as before.
591
+ """
592
+ wanted = sorted({sha for sha in shas if sha})
593
+ if not wanted:
594
+ return {}
595
+ try:
596
+ rows = await clickhouse.query(
597
+ 'SELECT sha, min(pushed_at) AS pushed_at FROM commits '
598
+ 'WHERE project_id = {pid:String} '
599
+ 'AND sha IN {shas:Array(String)} GROUP BY sha',
600
+ {'pid': project_id, 'shas': wanted},
601
+ )
602
+ except Exception: # noqa: BLE001
603
+ LOGGER.debug(
604
+ 'github-commit-sync stored pushed_at lookup failed for %s',
605
+ project_id,
606
+ exc_info=True,
607
+ )
608
+ return {}
609
+ out: dict[str, datetime.datetime] = {}
610
+ for row in rows:
611
+ pushed_at = clickhouse.as_utc_or_none(row.get('pushed_at'))
612
+ if row.get('sha') and pushed_at is not None:
613
+ out[str(row['sha'])] = pushed_at
614
+ return out
615
+
616
+
573
617
  async def _fetch_recent_commits(
574
618
  client: httpx.AsyncClient, head: str, limit: int, *, max_wait: float
575
619
  ) -> list[dict[str, typing.Any]]:
@@ -1030,12 +1074,17 @@ async def sync_commits(
1030
1074
  user_map = await _resolve_author_users(
1031
1075
  raw, ctx.resolve_user_by_identity, base
1032
1076
  )
1077
+ # A workflow_run re-sync of an already-stored commit must keep the
1078
+ # push time it was first recorded with; see _stored_pushed_at.
1079
+ stored = await _stored_pushed_at(
1080
+ ctx.project_id, (str(i['sha']) for i in raw if i.get('sha'))
1081
+ )
1033
1082
  records: list[pydantic.BaseModel] = [
1034
1083
  _commit_record(
1035
1084
  item,
1036
1085
  project_id=ctx.project_id,
1037
1086
  ref=ref,
1038
- pushed_at=pushed_at,
1087
+ pushed_at=stored.get(str(item['sha']), pushed_at),
1039
1088
  author_user=_author_user(item, user_map),
1040
1089
  ci_status=ci_by_sha.get(str(item['sha']), 'unknown'),
1041
1090
  )
@@ -1558,12 +1607,18 @@ class GitHubCommitSync(CommitSyncCapability):
1558
1607
  user_map = await _resolve_author_users(
1559
1608
  raw_commits, ctx.resolve_user_by_identity, base
1560
1609
  )
1610
+ # Re-running the backfill must not collapse every stored commit
1611
+ # onto one push time; only commits new to the table get now().
1612
+ stored = await _stored_pushed_at(
1613
+ ctx.project_id,
1614
+ (str(i['sha']) for i in raw_commits if i.get('sha')),
1615
+ )
1561
1616
  commit_records: list[pydantic.BaseModel] = [
1562
1617
  _commit_record(
1563
1618
  item,
1564
1619
  project_id=ctx.project_id,
1565
1620
  ref=branch,
1566
- pushed_at=pushed_at,
1621
+ pushed_at=stored.get(str(item['sha']), pushed_at),
1567
1622
  author_user=_author_user(item, user_map),
1568
1623
  ci_status=ci_by_sha.get(str(item['sha']), 'unknown'),
1569
1624
  )
@@ -1721,12 +1776,15 @@ class GitHubCommitSync(CommitSyncCapability):
1721
1776
  user_map = await _resolve_author_users(
1722
1777
  raw, ctx.resolve_user_by_identity, base
1723
1778
  )
1779
+ stored = await _stored_pushed_at(
1780
+ ctx.project_id, (str(i['sha']) for i in raw if i.get('sha'))
1781
+ )
1724
1782
  records: list[pydantic.BaseModel] = [
1725
1783
  _commit_record(
1726
1784
  item,
1727
1785
  project_id=ctx.project_id,
1728
1786
  ref=branch,
1729
- pushed_at=pushed_at,
1787
+ pushed_at=stored.get(str(item['sha']), pushed_at),
1730
1788
  author_user=_author_user(item, user_map),
1731
1789
  ci_status=ci_by_sha.get(str(item['sha']), 'unknown'),
1732
1790
  )
@@ -199,6 +199,10 @@ _ZERO_SHA = '0' * 40
199
199
  # GitHub's compare endpoint lists at most 300 changed files and offers
200
200
  # no pagination for them -- a list this long may be incomplete.
201
201
  _COMPARE_FILES_CAP = 300
202
+ # The compare endpoint pages its commits (250 per page) while ``ahead_by``
203
+ # counts the whole range, so ``compare`` follows the ``Link`` chain -- up
204
+ # to this many pages before it gives up on a pathological range.
205
+ _MAX_COMPARE_PAGES = 20
202
206
  # How many note-blob reads run at once when listing a whole notes tree.
203
207
  _NOTE_BLOB_CONCURRENCY = 10
204
208
 
@@ -1078,12 +1082,38 @@ class GitHubDeployment(DeploymentCapability):
1078
1082
  ctx, credentials, SCOPE_CONTENTS_READ
1079
1083
  ) as client:
1080
1084
  quoted = urllib.parse.quote(f'{base}...{head}', safe='.')
1081
- resp = await client.get(f'/compare/{quoted}')
1082
- resp.raise_for_status()
1083
- payload = typing.cast(dict[str, typing.Any], resp.json())
1084
- commits_raw: list[dict[str, typing.Any]] = (
1085
- payload.get('commits') or []
1086
- )
1085
+ path = f'/compare/{quoted}'
1086
+ params: dict[str, str] = {'per_page': '250'}
1087
+ payload: dict[str, typing.Any] = {}
1088
+ commits_raw: list[dict[str, typing.Any]] = []
1089
+ for page in range(1, _MAX_COMPARE_PAGES + 1):
1090
+ resp = await client.get(path, params=params)
1091
+ resp.raise_for_status()
1092
+ page_payload = typing.cast(dict[str, typing.Any], resp.json())
1093
+ if page == 1:
1094
+ # Only the first page carries ``files``, the
1095
+ # ``ahead_by``/``behind_by`` totals and the base.
1096
+ payload = page_payload
1097
+ commits_raw.extend(page_payload.get('commits') or [])
1098
+ next_url = _next_page_url(resp.headers.get('link'))
1099
+ if next_url is None:
1100
+ break
1101
+ next_page = _query_param(next_url, 'page')
1102
+ if next_page is None:
1103
+ break
1104
+ params['page'] = next_page
1105
+ else:
1106
+ # Still more pages after the cap: ``ahead`` keeps the
1107
+ # true count so a consumer can tell the list is short.
1108
+ LOGGER.warning(
1109
+ 'Compare %s...%s truncated at %d pages (%d of %s '
1110
+ 'commits); the commit list is incomplete',
1111
+ base,
1112
+ head,
1113
+ _MAX_COMPARE_PAGES,
1114
+ len(commits_raw),
1115
+ payload.get('ahead_by'),
1116
+ )
1087
1117
  commits: list[Commit] = [
1088
1118
  _commit_from_payload(item) for item in commits_raw
1089
1119
  ]
@@ -106,7 +106,10 @@ def _pr_record(
106
106
  state=str(pr.get('state') or 'open'),
107
107
  author=str(user.get('login') or ''),
108
108
  draft=bool(pr.get('draft', False)),
109
- merged=bool(pr.get('merged', False)),
109
+ # The list endpoint (``GET /pulls``) omits ``merged``; only the
110
+ # single-PR endpoint carries it. ``merged_at`` is set on both, so
111
+ # treat a merge timestamp as merged.
112
+ merged=bool(pr.get('merged') or pr.get('merged_at')),
110
113
  created_at=created_at,
111
114
  updated_at=updated_at,
112
115
  merged_at=_parse_pr_datetime(pr.get('merged_at')),
@@ -121,6 +121,35 @@ def _event(body: dict[str, typing.Any]) -> dict[str, typing.Any]:
121
121
  }
122
122
 
123
123
 
124
+ def _query_router(
125
+ *,
126
+ last_known: str | None = None,
127
+ stored: dict[str, datetime.datetime] | None = None,
128
+ ) -> mock.AsyncMock:
129
+ """A ``clickhouse.query`` stand-in that answers by statement shape.
130
+
131
+ ``stored`` maps sha -> the ``pushed_at`` already on record for it,
132
+ answering the carry-forward lookup; ``last_known`` answers the
133
+ ``_last_known_sha`` probe. Anything else reads as an empty table.
134
+ """
135
+
136
+ async def _answer(
137
+ sql: str, params: dict[str, typing.Any] | None = None
138
+ ) -> list[dict[str, typing.Any]]:
139
+ params = params or {}
140
+ if 'min(pushed_at)' in sql:
141
+ return [
142
+ {'sha': sha, 'pushed_at': pushed_at}
143
+ for sha, pushed_at in (stored or {}).items()
144
+ if sha in params.get('shas', ())
145
+ ]
146
+ if 'argMax(sha, pushed_at)' in sql:
147
+ return [{'sha': last_known}] if last_known else []
148
+ return []
149
+
150
+ return mock.AsyncMock(side_effect=_answer)
151
+
152
+
124
153
  def _commit(
125
154
  sha: str, *, login: str = 'octocat', author_id: int = 583231
126
155
  ) -> dict[str, object]:
@@ -392,6 +421,96 @@ class SyncCommitsTestCase(unittest.IsolatedAsyncioTestCase):
392
421
  self.assertEqual('main', records[0].ref)
393
422
  self.assertEqual('pass', records[0].ci_status)
394
423
 
424
+ @respx.mock
425
+ async def test_resync_keeps_the_stored_push_time(self) -> None:
426
+ """A re-sync of a stored commit must not re-stamp ``pushed_at``.
427
+
428
+ The re-inserted row wins under ``ReplacingMergeTree``, so a fresh
429
+ ``pushed_at`` lifts a commit stored days earlier above everything
430
+ pushed since -- and the recent-commits feed, and every release
431
+ range sliced out of it, is ordered by that column (#308). The
432
+ re-sync still refreshes what it is for: the CI status.
433
+ """
434
+ head = 'b' * 40
435
+ first_pushed = datetime.datetime(
436
+ 2026, 9, 9, 15, 8, 54, tzinfo=datetime.UTC
437
+ )
438
+ respx.get(
439
+ f'https://api.github.com/repos/octo/demo/commits/{head}'
440
+ ).mock(return_value=httpx.Response(200, json=_commit(head)))
441
+ respx.get(_check_runs_url(head)).mock(
442
+ return_value=httpx.Response(200, json=_check_runs('success'))
443
+ )
444
+ query = _query_router(stored={head: first_pushed})
445
+ with mock.patch(_QUERY, new=query):
446
+ with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
447
+ await commits.sync_commits(
448
+ ctx=_ctx(),
449
+ credentials=_CREDS,
450
+ external_identifier='',
451
+ action_config=_WORKFLOW_RUN_CONFIG,
452
+ event=_event(_workflow_run(head=head)),
453
+ )
454
+ _, records = _await_args(insert)
455
+ self.assertEqual(first_pushed, records[0].pushed_at)
456
+ self.assertEqual('pass', records[0].ci_status)
457
+ lookup = next(
458
+ c for c in query.await_args_list if 'min(pushed_at)' in c.args[0]
459
+ )
460
+ self.assertEqual([head], lookup.args[1]['shas'])
461
+
462
+ @respx.mock
463
+ async def test_only_new_commits_get_the_current_push_time(self) -> None:
464
+ base, head = 'a' * 40, 'b' * 40
465
+ respx.get(
466
+ f'https://api.github.com/repos/octo/demo/compare/{base}...{head}'
467
+ ).mock(
468
+ return_value=httpx.Response(
469
+ 200, json={'commits': [_commit('c' * 40), _commit('d' * 40)]}
470
+ )
471
+ )
472
+ first_pushed = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
473
+ with mock.patch(
474
+ _QUERY, new=_query_router(stored={'c' * 40: first_pushed})
475
+ ):
476
+ with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
477
+ await commits.sync_commits(
478
+ ctx=_ctx(),
479
+ credentials=_CREDS,
480
+ external_identifier='',
481
+ action_config=commits.SyncCommitsConfig(),
482
+ event=_event(_push(before=base, after=head)),
483
+ )
484
+ _, records = _await_args(insert)
485
+ by_sha = {r.sha: r for r in records}
486
+ self.assertEqual(first_pushed, by_sha['c' * 40].pushed_at)
487
+ self.assertGreater(by_sha['d' * 40].pushed_at, first_pushed)
488
+
489
+ @respx.mock
490
+ async def test_push_time_lookup_failure_stamps_now(self) -> None:
491
+ """The carry-forward is best-effort; a lookup error must not
492
+ cost the sync."""
493
+ head = 'b' * 40
494
+ respx.get(
495
+ f'https://api.github.com/repos/octo/demo/commits/{head}'
496
+ ).mock(return_value=httpx.Response(200, json=_commit(head)))
497
+ respx.get(_check_runs_url(head)).mock(
498
+ return_value=httpx.Response(200, json=_check_runs('success'))
499
+ )
500
+ failing = mock.AsyncMock(side_effect=RuntimeError('clickhouse down'))
501
+ with mock.patch(_QUERY, new=failing):
502
+ with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
503
+ await commits.sync_commits(
504
+ ctx=_ctx(),
505
+ credentials=_CREDS,
506
+ external_identifier='',
507
+ action_config=_WORKFLOW_RUN_CONFIG,
508
+ event=_event(_workflow_run(head=head)),
509
+ )
510
+ insert.assert_awaited_once()
511
+ _, records = _await_args(insert)
512
+ self.assertEqual(head, records[0].sha)
513
+
395
514
  @respx.mock
396
515
  async def test_absent_before_never_consults_last_known_sha(self) -> None:
397
516
  """The single-commit path must not fall through to a compare.
@@ -407,8 +526,11 @@ class SyncCommitsTestCase(unittest.IsolatedAsyncioTestCase):
407
526
  respx.get(_check_runs_url(head)).mock(
408
527
  return_value=httpx.Response(200, json=_check_runs('success'))
409
528
  )
529
+ compare = respx.get(url__regex=r'.*/compare/.+\.\.\..+').mock(
530
+ return_value=httpx.Response(200, json={'commits': []})
531
+ )
410
532
  with (
411
- mock.patch(_QUERY, new=mock.AsyncMock()) as query,
533
+ mock.patch(_QUERY, new=_query_router(last_known=head)) as query,
412
534
  mock.patch(_INSERT, new=mock.AsyncMock()) as insert,
413
535
  ):
414
536
  await commits.sync_commits(
@@ -418,7 +540,16 @@ class SyncCommitsTestCase(unittest.IsolatedAsyncioTestCase):
418
540
  action_config=_WORKFLOW_RUN_CONFIG,
419
541
  event=_event(_workflow_run(head=head)),
420
542
  )
421
- query.assert_not_awaited()
543
+ # The pushed_at carry-forward reads the table too; what must not
544
+ # run is the last-known-sha probe (and the compare it would feed).
545
+ self.assertFalse(
546
+ [
547
+ c
548
+ for c in query.await_args_list
549
+ if 'argMax(sha, pushed_at)' in c.args[0]
550
+ ]
551
+ )
552
+ self.assertFalse(compare.called)
422
553
  insert.assert_awaited_once()
423
554
 
424
555
  @respx.mock
@@ -1551,6 +1682,37 @@ class SyncAllHistoryTestCase(unittest.IsolatedAsyncioTestCase):
1551
1682
  urls,
1552
1683
  )
1553
1684
 
1685
+ @respx.mock
1686
+ async def test_rerun_keeps_stored_push_times(self) -> None:
1687
+ """A backfill re-run must not collapse the stored history onto one
1688
+ push time; only commits new to the table are stamped now."""
1689
+ self._mock_default_branch()
1690
+ respx.get(f'{self._REPO}/commits').mock(
1691
+ return_value=httpx.Response(
1692
+ 200, json=[_commit('c' * 40), _commit('d' * 40)]
1693
+ )
1694
+ )
1695
+ respx.get(f'{self._REPO}/releases').mock(
1696
+ return_value=httpx.Response(200, json=[])
1697
+ )
1698
+ respx.get(f'{self._REPO}/git/matching-refs/tags').mock(
1699
+ return_value=httpx.Response(200, json=[])
1700
+ )
1701
+ first_pushed = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
1702
+ with mock.patch(
1703
+ _QUERY, new=_query_router(stored={'d' * 40: first_pushed})
1704
+ ):
1705
+ with mock.patch(_INSERT, new=mock.AsyncMock()) as insert:
1706
+ await commits.GitHubCommitSync().sync_all_history(
1707
+ ctx=self._ctx(), credentials=_CREDS
1708
+ )
1709
+ commit_call = next(
1710
+ c for c in insert.await_args_list if c.args[0] == 'commits'
1711
+ )
1712
+ by_sha = {r.sha: r for r in commit_call.args[1]}
1713
+ self.assertEqual(first_pushed, by_sha['d' * 40].pushed_at)
1714
+ self.assertGreater(by_sha['c' * 40].pushed_at, first_pushed)
1715
+
1554
1716
  @respx.mock
1555
1717
  async def test_bounds_ci_status_to_recent_commits(self) -> None:
1556
1718
  deployment._CHECKS_DISABLED_TOKENS.clear()
@@ -2125,6 +2287,27 @@ class SyncNewCommitsTestCase(unittest.IsolatedAsyncioTestCase):
2125
2287
  record = insert.await_args_list[0].args[1][0]
2126
2288
  self.assertEqual('main', record.ref)
2127
2289
 
2290
+ @respx.mock
2291
+ async def test_keeps_the_stored_push_time_on_resync(self) -> None:
2292
+ self._mock_default_branch()
2293
+ respx.get(url__regex=r'.*/compare/.+\.\.\..+').mock(
2294
+ return_value=httpx.Response(
2295
+ 200, json={'commits': [_commit('c' * 40), _commit('d' * 40)]}
2296
+ )
2297
+ )
2298
+ first_pushed = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
2299
+ with mock.patch(
2300
+ _QUERY,
2301
+ new=_query_router(
2302
+ last_known='a' * 40, stored={'c' * 40: first_pushed}
2303
+ ),
2304
+ ):
2305
+ written, insert = await self._sync()
2306
+ self.assertEqual(2, written)
2307
+ by_sha = {r.sha: r for r in insert.await_args_list[0].args[1]}
2308
+ self.assertEqual(first_pushed, by_sha['c' * 40].pushed_at)
2309
+ self.assertGreater(by_sha['d' * 40].pushed_at, first_pushed)
2310
+
2128
2311
  @respx.mock
2129
2312
  async def test_since_window_when_nothing_stored(self) -> None:
2130
2313
  self._mock_default_branch()
@@ -6,6 +6,7 @@ import datetime
6
6
  import json
7
7
  import time
8
8
  import unittest
9
+ import unittest.mock
9
10
 
10
11
  import httpx
11
12
  import respx
@@ -717,6 +718,98 @@ class CompareTestCase(unittest.IsolatedAsyncioTestCase):
717
718
  self.assertEqual(result.commits[1].message, 'Subject only')
718
719
  self.assertIsNone(result.commits[1].body)
719
720
 
721
+ @respx.mock
722
+ async def test_compare_follows_next_link(self) -> None:
723
+ url = 'https://api.github.com/repos/octo/demo/compare/base...head'
724
+ page2_link = f'{url}?per_page=250&page=2'
725
+ # More-specific (page=2) matcher first; see ListRefsPagination.
726
+ respx.get(url, params={'per_page': '250', 'page': '2'}).mock(
727
+ return_value=httpx.Response(
728
+ 200,
729
+ json={
730
+ 'ahead_by': 3,
731
+ 'commits': [
732
+ {
733
+ 'sha': 'c3',
734
+ 'commit': {
735
+ 'message': 'three',
736
+ 'author': {'name': 'A', 'date': None},
737
+ },
738
+ },
739
+ ],
740
+ },
741
+ )
742
+ )
743
+ respx.get(url, params={'per_page': '250'}).mock(
744
+ return_value=httpx.Response(
745
+ 200,
746
+ json={
747
+ 'ahead_by': 3,
748
+ 'behind_by': 0,
749
+ 'base_commit': {'sha': 'base-sha'},
750
+ 'commits': [
751
+ {
752
+ 'sha': 'c1',
753
+ 'commit': {
754
+ 'message': 'one',
755
+ 'author': {'name': 'A', 'date': None},
756
+ },
757
+ },
758
+ {
759
+ 'sha': 'c2',
760
+ 'commit': {
761
+ 'message': 'two',
762
+ 'author': {'name': 'A', 'date': None},
763
+ },
764
+ },
765
+ ],
766
+ 'files': [{'additions': 1, 'deletions': 0}],
767
+ },
768
+ headers={'Link': f'<{page2_link}>; rel="next"'},
769
+ )
770
+ )
771
+ plugin = GitHubDeployment()
772
+ result = await plugin.compare(_ctx(), _CREDS, 'base', 'head')
773
+ self.assertEqual([c.sha for c in result.commits], ['c1', 'c2', 'c3'])
774
+ self.assertEqual(result.head_sha, 'c3')
775
+ self.assertEqual(result.base_sha, 'base-sha')
776
+ self.assertEqual(result.ahead, 3)
777
+ self.assertEqual(result.files_changed, 1)
778
+ self.assertEqual(result.additions, 1)
779
+
780
+ @respx.mock
781
+ async def test_compare_warns_when_page_walk_truncates(self) -> None:
782
+ url = 'https://api.github.com/repos/octo/demo/compare/base...head'
783
+ respx.get(url).mock(
784
+ return_value=httpx.Response(
785
+ 200,
786
+ json={
787
+ 'ahead_by': 500,
788
+ 'commits': [
789
+ {
790
+ 'sha': 'c1',
791
+ 'commit': {
792
+ 'message': 'one',
793
+ 'author': {'name': 'A', 'date': None},
794
+ },
795
+ },
796
+ ],
797
+ },
798
+ headers={'Link': f'<{url}?per_page=250&page=2>; rel="next"'},
799
+ )
800
+ )
801
+ plugin = GitHubDeployment()
802
+ with unittest.mock.patch(
803
+ 'imbi.plugins.github.deployment._MAX_COMPARE_PAGES', 1
804
+ ):
805
+ with self.assertLogs(
806
+ 'imbi.plugins.github', level='WARNING'
807
+ ) as logs:
808
+ result = await plugin.compare(_ctx(), _CREDS, 'base', 'head')
809
+ self.assertEqual(len(result.commits), 1)
810
+ self.assertEqual(result.ahead, 500)
811
+ self.assertIn('truncated', logs.output[0])
812
+
720
813
 
721
814
  class TriggerDeploymentTestCase(unittest.IsolatedAsyncioTestCase):
722
815
  @respx.mock
@@ -103,6 +103,31 @@ class ManifestTestCase(unittest.TestCase):
103
103
  self.assertIn('sync_pull_requests', names)
104
104
 
105
105
 
106
+ class PrRecordTestCase(unittest.TestCase):
107
+ def test_merged_from_flag(self) -> None:
108
+ record = pull_requests._pr_record(
109
+ _pr(state='closed', merged=True, merged_at='2026-01-03T00:00:00Z'),
110
+ project_id='p1',
111
+ )
112
+ assert record is not None
113
+ self.assertTrue(record.merged)
114
+
115
+ def test_merged_inferred_from_merged_at_on_list_payload(self) -> None:
116
+ # ``GET /pulls`` (the backfill source) has no ``merged`` key.
117
+ pr = _pr(state='closed', merged_at='2026-01-03T00:00:00Z')
118
+ del pr['merged']
119
+ record = pull_requests._pr_record(pr, project_id='p1')
120
+ assert record is not None
121
+ self.assertTrue(record.merged)
122
+
123
+ def test_closed_without_merge_is_not_merged(self) -> None:
124
+ pr = _pr(state='closed')
125
+ del pr['merged']
126
+ record = pull_requests._pr_record(pr, project_id='p1')
127
+ assert record is not None
128
+ self.assertFalse(record.merged)
129
+
130
+
106
131
  class SyncPullRequestsActionTestCase(unittest.IsolatedAsyncioTestCase):
107
132
  async def test_opened_records_one_row(self) -> None:
108
133
  with mock.patch(_INSERT_WEBHOOK, new=mock.AsyncMock()) as insert: