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.
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/PKG-INFO +2 -2
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/pyproject.toml +2 -2
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/commits.py +61 -3
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/deployment.py +36 -6
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/pull_requests.py +4 -1
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_commits.py +185 -2
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_deployment.py +93 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_pull_requests.py +25 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/.gitignore +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/README.md +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/moon.yml +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/README.md +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/__init__.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/_app_auth.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/_hosts.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/_repos.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/doctor.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/identity.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/lifecycle.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/plugin.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/py.typed +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/__init__.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_app_auth_scopes.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_doctor.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_hosts.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_identity.py +0 -0
- {imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/tests/test_lifecycle.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
)
|
{imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/deployment.py
RENAMED
|
@@ -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
|
-
|
|
1082
|
-
|
|
1083
|
-
payload
|
|
1084
|
-
commits_raw: list[dict[str, typing.Any]] =
|
|
1085
|
-
|
|
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
|
]
|
{imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/pull_requests.py
RENAMED
|
@@ -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
|
-
|
|
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=
|
|
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
|
-
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/_app_auth.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{imbi_plugin_github-2.32.3 → imbi_plugin_github-2.33.0}/src/imbi/plugins/github/lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|