imbi-plugin-github 2.43.0__py3-none-any.whl → 2.44.0__py3-none-any.whl
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/plugins/github/README.md +4 -1
- imbi/plugins/github/plugin.py +2 -0
- imbi/plugins/github/pull_requests.py +535 -28
- {imbi_plugin_github-2.43.0.dist-info → imbi_plugin_github-2.44.0.dist-info}/METADATA +31 -2
- {imbi_plugin_github-2.43.0.dist-info → imbi_plugin_github-2.44.0.dist-info}/RECORD +6 -6
- {imbi_plugin_github-2.43.0.dist-info → imbi_plugin_github-2.44.0.dist-info}/WHEEL +0 -0
imbi/plugins/github/README.md
CHANGED
|
@@ -61,9 +61,12 @@ dispatches from webhook deliveries. A `WebhookRule.handler` is
|
|
|
61
61
|
| `sync_commits` | `github#sync_commits` | `commits` |
|
|
62
62
|
| `sync_tags` | `github#sync_tags` | `tags` |
|
|
63
63
|
| `sync_pull_requests` | `github#sync_pull_requests` | `pull_requests` |
|
|
64
|
+
| `sync_pull_request_reviews` | `github#sync_pull_request_reviews` | `pull_request_reviews` |
|
|
64
65
|
|
|
65
66
|
`sync_commits` and `sync_tags` are dispatched from `push` deliveries;
|
|
66
|
-
`sync_pull_requests` from `pull_request` deliveries
|
|
67
|
+
`sync_pull_requests` from `pull_request` deliveries, and
|
|
68
|
+
`sync_pull_request_reviews` from `pull_request_review` deliveries (the
|
|
69
|
+
webhook or GitHub App must subscribe to "Pull request reviews").
|
|
67
70
|
|
|
68
71
|
`sync_commits` fetches the full set of commits in a push via the compare
|
|
69
72
|
API (paginated, so it isn't capped by the 20-commit inline payload limit);
|
imbi/plugins/github/plugin.py
CHANGED
|
@@ -38,6 +38,7 @@ from imbi.plugins.github.identity import DEFAULT_SCOPES, GitHubIdentity
|
|
|
38
38
|
from imbi.plugins.github.lifecycle import GitHubLifecycle
|
|
39
39
|
from imbi.plugins.github.pull_requests import (
|
|
40
40
|
GitHubPullRequestSync,
|
|
41
|
+
sync_pull_request_reviews_descriptor,
|
|
41
42
|
sync_pull_requests_descriptor,
|
|
42
43
|
)
|
|
43
44
|
from imbi.plugins.github.secret_provider import GitHubApp
|
|
@@ -58,6 +59,7 @@ class GitHubWebhookActions(WebhookActionsCapability):
|
|
|
58
59
|
sync_commits_descriptor,
|
|
59
60
|
sync_tags_descriptor,
|
|
60
61
|
sync_pull_requests_descriptor,
|
|
62
|
+
sync_pull_request_reviews_descriptor,
|
|
61
63
|
]
|
|
62
64
|
|
|
63
65
|
@classmethod
|
|
@@ -22,6 +22,7 @@ gateway's own event recording behaves.
|
|
|
22
22
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
|
+
import asyncio
|
|
25
26
|
import datetime
|
|
26
27
|
import logging
|
|
27
28
|
import typing
|
|
@@ -30,17 +31,27 @@ import httpx
|
|
|
30
31
|
import jsonpointer
|
|
31
32
|
import pydantic
|
|
32
33
|
|
|
33
|
-
from imbi.common import iggy
|
|
34
|
+
from imbi.common import clickhouse, iggy
|
|
34
35
|
from imbi.common.json_pointer import JsonPointer
|
|
35
|
-
from imbi.common.models import
|
|
36
|
+
from imbi.common.models import (
|
|
37
|
+
PullRequestRecord,
|
|
38
|
+
PullRequestReviewRecord,
|
|
39
|
+
PullRequestReviewRequestsRecord,
|
|
40
|
+
)
|
|
36
41
|
from imbi.common.plugins.base import (
|
|
37
42
|
ActionDescriptor,
|
|
38
43
|
PluginContext,
|
|
39
44
|
PullRequestSyncCapability,
|
|
40
45
|
)
|
|
46
|
+
from imbi.common.plugins.errors import PluginRateLimited
|
|
41
47
|
from imbi.plugins.github._app_auth import AppNotInstalledError
|
|
42
48
|
from imbi.plugins.github._hosts import host_to_api_base
|
|
43
|
-
from imbi.plugins.github._repos import
|
|
49
|
+
from imbi.plugins.github._repos import (
|
|
50
|
+
RepositoryTarget,
|
|
51
|
+
parse_owner_repo,
|
|
52
|
+
resolve_owner_repo,
|
|
53
|
+
resolve_repository_target,
|
|
54
|
+
)
|
|
44
55
|
from imbi.plugins.github.commits import (
|
|
45
56
|
_BACKFILL_MAX_WAIT_SECONDS, # pyright: ignore[reportPrivateUsage]
|
|
46
57
|
_client, # pyright: ignore[reportPrivateUsage]
|
|
@@ -64,8 +75,61 @@ _SELF_SLUG = 'github-pr-sync'
|
|
|
64
75
|
_MAX_HISTORY_PAGES = 100
|
|
65
76
|
# Webhook actions handle these PR lifecycle transitions; synchronize
|
|
66
77
|
# fires when new commits are pushed to the PR branch (title/state may
|
|
67
|
-
# also change).
|
|
68
|
-
|
|
78
|
+
# also change). The review-request actions keep ``requested_reviewers``
|
|
79
|
+
# current.
|
|
80
|
+
_SYNC_ACTIONS = frozenset(
|
|
81
|
+
{
|
|
82
|
+
'opened',
|
|
83
|
+
'closed',
|
|
84
|
+
'reopened',
|
|
85
|
+
'synchronize',
|
|
86
|
+
'review_requested',
|
|
87
|
+
'review_request_removed',
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
# ``pull_request_review`` actions that change a review's state.
|
|
91
|
+
_REVIEW_ACTIONS = frozenset({'submitted', 'edited', 'dismissed'})
|
|
92
|
+
# GitHub's review states, lower-cased. ``PENDING`` is an unsubmitted
|
|
93
|
+
# draft review only its author can see, so it is not recorded.
|
|
94
|
+
_REVIEW_STATES = frozenset(
|
|
95
|
+
{'approved', 'changes_requested', 'commented', 'dismissed'}
|
|
96
|
+
)
|
|
97
|
+
# Cap per-PR review pages during backfill (100 reviews per page).
|
|
98
|
+
_MAX_REVIEW_PAGES = 10
|
|
99
|
+
# Open PRs whose reviews the backfill fetches at once.
|
|
100
|
+
_REVIEW_FETCH_CONCURRENCY = 5
|
|
101
|
+
# Stale rows: the project's ``open`` rows on the synced host that a
|
|
102
|
+
# complete listing didn't return. Only ``open`` rows, because that is
|
|
103
|
+
# the symptom (a PR stuck open forever); closed and merged history
|
|
104
|
+
# stays even when the listing no longer has it, e.g. after the project
|
|
105
|
+
# moves to a new repository. The ``url`` prefix limits it to the synced
|
|
106
|
+
# host: a project can bind more than one pr-sync Integration.
|
|
107
|
+
# ``cutoff`` spares rows recorded during (or just before) the sync.
|
|
108
|
+
_SELECT_STALE_SQL = (
|
|
109
|
+
'SELECT pr_id, url FROM pull_requests FINAL '
|
|
110
|
+
'WHERE project_id = {project_id:String} '
|
|
111
|
+
"AND state = 'open' "
|
|
112
|
+
'AND startsWith(url, {url_prefix:String}) '
|
|
113
|
+
"AND recorded_at < {cutoff:DateTime64(3, 'UTC')}"
|
|
114
|
+
)
|
|
115
|
+
_DELETE_STALE_SQL = (
|
|
116
|
+
'DELETE FROM pull_requests '
|
|
117
|
+
'WHERE project_id = {project_id:String} '
|
|
118
|
+
'AND startsWith(url, {url_prefix:String}) '
|
|
119
|
+
"AND recorded_at < {cutoff:DateTime64(3, 'UTC')}"
|
|
120
|
+
)
|
|
121
|
+
# ``cutoff`` is typed ``DateTime64(3, 'UTC')``: clickhouse-connect formats
|
|
122
|
+
# a bound datetime in the zone its placeholder type names, so the text and
|
|
123
|
+
# the type agree on any server. Without the zone the text is server-local
|
|
124
|
+
# wall time, which is ambiguous for an hour when DST falls back.
|
|
125
|
+
# ``recorded_at`` is stamped by whichever host wrote the row (this
|
|
126
|
+
# worker, or the gateway for webhook rows), so back the cutoff off to
|
|
127
|
+
# absorb clock skew and GitHub's listing lag. Stale rows are old.
|
|
128
|
+
_PRUNE_MARGIN = datetime.timedelta(minutes=5)
|
|
129
|
+
# Each bound array goes to ClickHouse as one HTTP field, capped at
|
|
130
|
+
# 128 KiB by default. A full 10k-PR listing in one array overruns it,
|
|
131
|
+
# so the listed IDs are split across arrays of at most this many.
|
|
132
|
+
_PRUNE_CHUNK_SIZE = 1000
|
|
69
133
|
|
|
70
134
|
|
|
71
135
|
def _parse_pr_datetime(value: object) -> datetime.datetime | None:
|
|
@@ -120,17 +184,211 @@ def _pr_record(
|
|
|
120
184
|
)
|
|
121
185
|
|
|
122
186
|
|
|
123
|
-
|
|
187
|
+
def _review_requests_record(
|
|
188
|
+
pr: dict[str, typing.Any],
|
|
189
|
+
*,
|
|
190
|
+
project_id: str,
|
|
191
|
+
) -> PullRequestReviewRequestsRecord | None:
|
|
192
|
+
"""The PR's pending review requests, or ``None`` without an id.
|
|
193
|
+
|
|
194
|
+
Both ``pull_request`` and ``pull_request_review`` payloads carry
|
|
195
|
+
the PR's current request lists, so either one can write this row.
|
|
196
|
+
The row is versioned by the PR's ``updated_at``, not the time Imbi
|
|
197
|
+
wrote it: a backfill builds rows from a listing fetched at the start
|
|
198
|
+
of a long walk, and must not replace a newer webhook row.
|
|
199
|
+
"""
|
|
200
|
+
pr_id = pr.get('id')
|
|
201
|
+
if not pr_id:
|
|
202
|
+
return None
|
|
203
|
+
record = PullRequestReviewRequestsRecord(
|
|
204
|
+
project_id=project_id,
|
|
205
|
+
pr_id=str(pr_id),
|
|
206
|
+
requested_reviewers=_logins(pr.get('requested_reviewers'), 'login'),
|
|
207
|
+
requested_teams=_logins(pr.get('requested_teams'), 'slug'),
|
|
208
|
+
)
|
|
209
|
+
updated_at = _parse_pr_datetime(pr.get('updated_at'))
|
|
210
|
+
if updated_at is not None:
|
|
211
|
+
record.recorded_at = updated_at
|
|
212
|
+
return record
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _logins(value: object, key: str) -> list[str]:
|
|
216
|
+
"""Pull ``key`` out of each object in a GitHub user/team list."""
|
|
217
|
+
if not isinstance(value, list):
|
|
218
|
+
return []
|
|
219
|
+
out: list[str] = []
|
|
220
|
+
for item in typing.cast('list[object]', value):
|
|
221
|
+
if isinstance(item, dict):
|
|
222
|
+
name = typing.cast('dict[str, typing.Any]', item).get(key)
|
|
223
|
+
if isinstance(name, str) and name:
|
|
224
|
+
out.append(name)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _review_record(
|
|
229
|
+
review: dict[str, typing.Any],
|
|
230
|
+
*,
|
|
231
|
+
project_id: str,
|
|
232
|
+
pr_id: str,
|
|
233
|
+
) -> PullRequestReviewRecord | None:
|
|
234
|
+
"""Map a GitHub review object onto a review record.
|
|
235
|
+
|
|
236
|
+
Returns ``None`` for pending (unsubmitted) reviews and for reviews
|
|
237
|
+
missing an id, reviewer, or submission time.
|
|
238
|
+
"""
|
|
239
|
+
review_id = review.get('id')
|
|
240
|
+
state = str(review.get('state') or '').lower()
|
|
241
|
+
user: dict[str, typing.Any] = review.get('user') or {}
|
|
242
|
+
login = user.get('login')
|
|
243
|
+
submitted_at = _parse_pr_datetime(review.get('submitted_at'))
|
|
244
|
+
if (
|
|
245
|
+
not review_id
|
|
246
|
+
or state not in _REVIEW_STATES
|
|
247
|
+
or not isinstance(login, str)
|
|
248
|
+
or not login
|
|
249
|
+
or submitted_at is None
|
|
250
|
+
):
|
|
251
|
+
return None
|
|
252
|
+
return PullRequestReviewRecord(
|
|
253
|
+
project_id=project_id,
|
|
254
|
+
pr_id=pr_id,
|
|
255
|
+
review_id=str(review_id),
|
|
256
|
+
reviewer=login,
|
|
257
|
+
state=state,
|
|
258
|
+
submitted_at=submitted_at,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def _fetch_reviews(
|
|
124
263
|
client: httpx.AsyncClient,
|
|
264
|
+
pr_number: int,
|
|
125
265
|
*,
|
|
126
266
|
max_wait: float,
|
|
127
267
|
) -> list[dict[str, typing.Any]]:
|
|
128
|
-
"""Walk ``GET /pulls
|
|
268
|
+
"""Walk ``GET /pulls/{number}/reviews`` for one PR.
|
|
269
|
+
|
|
270
|
+
A 404 (PR or repo gone) returns what was collected so far. GitHub
|
|
271
|
+
lists reviews oldest first, so hitting the page cap drops the
|
|
272
|
+
newest; that is logged.
|
|
273
|
+
"""
|
|
274
|
+
out: list[dict[str, typing.Any]] = []
|
|
275
|
+
params: dict[str, str] = {'per_page': '100'}
|
|
276
|
+
for _ in range(_MAX_REVIEW_PAGES):
|
|
277
|
+
resp = await _request(
|
|
278
|
+
client,
|
|
279
|
+
'GET',
|
|
280
|
+
f'/pulls/{pr_number}/reviews',
|
|
281
|
+
params=params,
|
|
282
|
+
max_wait=max_wait,
|
|
283
|
+
)
|
|
284
|
+
if resp.status_code == 404:
|
|
285
|
+
return out
|
|
286
|
+
resp.raise_for_status()
|
|
287
|
+
page: object = resp.json()
|
|
288
|
+
if not isinstance(page, list):
|
|
289
|
+
LOGGER.warning(
|
|
290
|
+
'github-pr-sync: reviews for PR #%d were not a list; skipping',
|
|
291
|
+
pr_number,
|
|
292
|
+
)
|
|
293
|
+
break
|
|
294
|
+
out.extend(
|
|
295
|
+
typing.cast('dict[str, typing.Any]', item)
|
|
296
|
+
for item in typing.cast('list[object]', page)
|
|
297
|
+
if isinstance(item, dict)
|
|
298
|
+
)
|
|
299
|
+
next_url = _next_page_url(resp.headers.get('link'))
|
|
300
|
+
next_page = _query_param(next_url, 'page') if next_url else None
|
|
301
|
+
if not page or next_page is None:
|
|
302
|
+
break
|
|
303
|
+
params['page'] = next_page
|
|
304
|
+
else:
|
|
305
|
+
LOGGER.warning(
|
|
306
|
+
'github-pr-sync: reached %d-page cap fetching reviews for PR '
|
|
307
|
+
'#%d; its newest reviews are missing',
|
|
308
|
+
_MAX_REVIEW_PAGES,
|
|
309
|
+
pr_number,
|
|
310
|
+
)
|
|
311
|
+
return out
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def _open_pr_reviews(
|
|
315
|
+
client: httpx.AsyncClient,
|
|
316
|
+
raw_prs: list[dict[str, typing.Any]],
|
|
317
|
+
*,
|
|
318
|
+
project_id: str,
|
|
319
|
+
max_wait: float,
|
|
320
|
+
) -> list[pydantic.BaseModel]:
|
|
321
|
+
"""Fetch review records for the open PRs in ``raw_prs``.
|
|
322
|
+
|
|
323
|
+
Only open PRs: the report that shows reviewers lists open PRs, and
|
|
324
|
+
walking reviews for a repo's whole history would cost one request
|
|
325
|
+
per PR ever opened. Best-effort per PR: a failed fetch is logged
|
|
326
|
+
and that PR's reviews are skipped (its webhooks fill them in). A
|
|
327
|
+
rate limit stops the rest of the fetches but keeps what came back.
|
|
328
|
+
"""
|
|
329
|
+
semaphore = asyncio.Semaphore(_REVIEW_FETCH_CONCURRENCY)
|
|
330
|
+
limited = asyncio.Event()
|
|
331
|
+
|
|
332
|
+
async def one(pr_id: str, number: int) -> list[pydantic.BaseModel]:
|
|
333
|
+
async with semaphore:
|
|
334
|
+
if limited.is_set():
|
|
335
|
+
return []
|
|
336
|
+
try:
|
|
337
|
+
reviews = await _fetch_reviews(
|
|
338
|
+
client, number, max_wait=max_wait
|
|
339
|
+
)
|
|
340
|
+
except PluginRateLimited:
|
|
341
|
+
if not limited.is_set():
|
|
342
|
+
LOGGER.warning(
|
|
343
|
+
'github-pr-sync: rate limited fetching reviews for '
|
|
344
|
+
'project %s; skipping the rest',
|
|
345
|
+
project_id,
|
|
346
|
+
)
|
|
347
|
+
limited.set()
|
|
348
|
+
return []
|
|
349
|
+
except httpx.HTTPError, ValueError:
|
|
350
|
+
# ValueError covers a body that is not valid JSON.
|
|
351
|
+
LOGGER.exception(
|
|
352
|
+
'github-pr-sync: failed to fetch reviews for PR #%d of '
|
|
353
|
+
'project %s',
|
|
354
|
+
number,
|
|
355
|
+
project_id,
|
|
356
|
+
)
|
|
357
|
+
return []
|
|
358
|
+
out: list[pydantic.BaseModel] = []
|
|
359
|
+
for review in reviews:
|
|
360
|
+
record = _review_record(review, project_id=project_id, pr_id=pr_id)
|
|
361
|
+
if record is not None:
|
|
362
|
+
out.append(record)
|
|
363
|
+
return out
|
|
364
|
+
|
|
365
|
+
jobs = [
|
|
366
|
+
one(str(pr['id']), pr['number'])
|
|
367
|
+
for pr in raw_prs
|
|
368
|
+
if pr.get('state') == 'open'
|
|
369
|
+
and pr.get('id')
|
|
370
|
+
and isinstance(pr.get('number'), int)
|
|
371
|
+
]
|
|
372
|
+
results = await asyncio.gather(*jobs)
|
|
373
|
+
return [record for batch in results for record in batch]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def _fetch_all_prs(
|
|
377
|
+
client: httpx.AsyncClient,
|
|
378
|
+
*,
|
|
379
|
+
max_wait: float,
|
|
380
|
+
) -> tuple[list[dict[str, typing.Any]], bool]:
|
|
381
|
+
"""Walk ``GET /pulls?state=all`` and return ``(prs, complete)``.
|
|
129
382
|
|
|
130
383
|
GitHub's list-PRs endpoint omits ``additions``, ``deletions``, and
|
|
131
384
|
``changed_files``; backfill rows store ``0`` for those fields.
|
|
132
385
|
Caps at ``_MAX_HISTORY_PAGES`` pages (10k PRs) so a repo with a very
|
|
133
386
|
large PR history can't pin the worker indefinitely.
|
|
387
|
+
|
|
388
|
+
``complete`` is ``False`` when any page returns 404, a next-page
|
|
389
|
+
link can't be followed, or the page cap is reached, so the caller
|
|
390
|
+
knows the listing may be missing PRs. Only a page with no next
|
|
391
|
+
link (or an empty page) ends the listing as complete.
|
|
134
392
|
"""
|
|
135
393
|
out: list[dict[str, typing.Any]] = []
|
|
136
394
|
params: dict[str, str] = {'state': 'all', 'per_page': '100'}
|
|
@@ -152,7 +410,7 @@ async def _fetch_all_prs(
|
|
|
152
410
|
'or removed); nothing to sync',
|
|
153
411
|
str(client.base_url).rstrip('/'),
|
|
154
412
|
)
|
|
155
|
-
return out
|
|
413
|
+
return out, False
|
|
156
414
|
resp.raise_for_status()
|
|
157
415
|
page: list[dict[str, typing.Any]] = resp.json()
|
|
158
416
|
if not page:
|
|
@@ -163,7 +421,13 @@ async def _fetch_all_prs(
|
|
|
163
421
|
break
|
|
164
422
|
next_page = _query_param(next_url, 'page')
|
|
165
423
|
if next_page is None:
|
|
166
|
-
|
|
424
|
+
LOGGER.warning(
|
|
425
|
+
'github-pr-sync: next-page link without a page param '
|
|
426
|
+
'(%s); stopping after %d PRs',
|
|
427
|
+
next_url,
|
|
428
|
+
len(out),
|
|
429
|
+
)
|
|
430
|
+
return out, False
|
|
167
431
|
params['page'] = next_page
|
|
168
432
|
else:
|
|
169
433
|
LOGGER.warning(
|
|
@@ -171,7 +435,102 @@ async def _fetch_all_prs(
|
|
|
171
435
|
'history may be truncated',
|
|
172
436
|
_MAX_HISTORY_PAGES,
|
|
173
437
|
)
|
|
174
|
-
|
|
438
|
+
return out, False
|
|
439
|
+
return out, True
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _is_repository_link(
|
|
443
|
+
ctx: PluginContext, host: str, target: RepositoryTarget
|
|
444
|
+
) -> bool:
|
|
445
|
+
"""Whether ``target`` came from the project's repository link.
|
|
446
|
+
|
|
447
|
+
``resolve_repository_target`` also accepts any other same-host
|
|
448
|
+
link, which may name some other repo. Only the Integration's own
|
|
449
|
+
link key or ``github-repository`` says which repo this project is.
|
|
450
|
+
"""
|
|
451
|
+
for key in (ctx.integration_slug, 'github-repository'):
|
|
452
|
+
url = ctx.project_links.get(key) if key else None
|
|
453
|
+
if url is None:
|
|
454
|
+
continue
|
|
455
|
+
if parse_owner_repo(url, host.lower()) == (target.owner, target.repo):
|
|
456
|
+
return True
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _id_arrays(
|
|
461
|
+
parameters: dict[str, typing.Any], prefix: str, ids: list[str]
|
|
462
|
+
) -> list[str]:
|
|
463
|
+
"""Bind ``ids`` as arrays of at most ``_PRUNE_CHUNK_SIZE``.
|
|
464
|
+
|
|
465
|
+
Returns the placeholder for each array, e.g.
|
|
466
|
+
``{prefix_0:Array(String)}``.
|
|
467
|
+
"""
|
|
468
|
+
placeholders: list[str] = []
|
|
469
|
+
for offset in range(0, len(ids), _PRUNE_CHUNK_SIZE):
|
|
470
|
+
name = f'{prefix}_{offset // _PRUNE_CHUNK_SIZE}'
|
|
471
|
+
parameters[name] = ids[offset : offset + _PRUNE_CHUNK_SIZE]
|
|
472
|
+
placeholders.append(f'{{{name}:Array(String)}}')
|
|
473
|
+
return placeholders
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
async def _prune_stale_prs(
|
|
477
|
+
project_id: str,
|
|
478
|
+
host: str,
|
|
479
|
+
started_at: datetime.datetime,
|
|
480
|
+
pr_ids: list[str],
|
|
481
|
+
) -> int:
|
|
482
|
+
"""Delete ``project_id``'s stale ``open`` rows on ``host``.
|
|
483
|
+
|
|
484
|
+
A row is stale when its ``pr_id`` is not in ``pr_ids`` (the complete
|
|
485
|
+
listing) and it was recorded before ``started_at`` less
|
|
486
|
+
``_PRUNE_MARGIN``. The stale rows are selected and logged first,
|
|
487
|
+
and only those ``pr_id``s are deleted, so the log says exactly what
|
|
488
|
+
was removed and a sync with nothing stale issues no mutation.
|
|
489
|
+
|
|
490
|
+
Returns the number of PRs deleted. Best-effort: a failure is logged
|
|
491
|
+
and swallowed so a ClickHouse problem never fails the sync.
|
|
492
|
+
"""
|
|
493
|
+
parameters: dict[str, typing.Any] = {
|
|
494
|
+
'project_id': project_id,
|
|
495
|
+
'url_prefix': f'https://{host.lower()}/',
|
|
496
|
+
# clickhouse-connect formats a plain ``datetime`` to whole
|
|
497
|
+
# seconds; the ``_64`` suffix binds it as ``cutoff`` with its
|
|
498
|
+
# fractional seconds kept.
|
|
499
|
+
'cutoff_64': started_at - _PRUNE_MARGIN,
|
|
500
|
+
}
|
|
501
|
+
select = _SELECT_STALE_SQL + ''.join(
|
|
502
|
+
f' AND pr_id NOT IN {placeholder}'
|
|
503
|
+
for placeholder in _id_arrays(parameters, 'listed', pr_ids)
|
|
504
|
+
)
|
|
505
|
+
try:
|
|
506
|
+
rows = await clickhouse.query(select, parameters)
|
|
507
|
+
if not rows:
|
|
508
|
+
return 0
|
|
509
|
+
stale = sorted(str(row['pr_id']) for row in rows)
|
|
510
|
+
LOGGER.warning(
|
|
511
|
+
'github-pr-sync: removing %d stale open PR row(s) for project '
|
|
512
|
+
'%s that %s no longer lists: %s',
|
|
513
|
+
len(stale),
|
|
514
|
+
project_id,
|
|
515
|
+
host,
|
|
516
|
+
', '.join(sorted(str(row['url']) for row in rows)),
|
|
517
|
+
)
|
|
518
|
+
delete = _DELETE_STALE_SQL + (
|
|
519
|
+
' AND ('
|
|
520
|
+
+ ' OR '.join(
|
|
521
|
+
f'pr_id IN {placeholder}'
|
|
522
|
+
for placeholder in _id_arrays(parameters, 'stale', stale)
|
|
523
|
+
)
|
|
524
|
+
+ ')'
|
|
525
|
+
)
|
|
526
|
+
await clickhouse.command(delete, parameters)
|
|
527
|
+
except Exception:
|
|
528
|
+
LOGGER.exception(
|
|
529
|
+
'github-pr-sync: failed to remove stale PR rows for project %s',
|
|
530
|
+
project_id,
|
|
531
|
+
)
|
|
532
|
+
return 0
|
|
533
|
+
return len(stale)
|
|
175
534
|
|
|
176
535
|
|
|
177
536
|
class SyncPRsConfig(pydantic.BaseModel):
|
|
@@ -212,10 +571,10 @@ async def sync_pull_requests(
|
|
|
212
571
|
) -> None:
|
|
213
572
|
"""Sync a pull_request event delivery into the ``pull_requests`` table.
|
|
214
573
|
|
|
215
|
-
Handles ``opened``, ``closed``, ``reopened``,
|
|
216
|
-
|
|
217
|
-
silently ignored -- the rule's CEL
|
|
218
|
-
this action is defensive too.
|
|
574
|
+
Handles ``opened``, ``closed``, ``reopened``, ``synchronize``,
|
|
575
|
+
``review_requested``, and ``review_request_removed`` actions. Other
|
|
576
|
+
actions (e.g. ``labeled``) are silently ignored -- the rule's CEL
|
|
577
|
+
filter can pre-screen them, but this action is defensive too.
|
|
219
578
|
|
|
220
579
|
The full PR object is in the webhook payload so no extra GitHub API
|
|
221
580
|
call is needed.
|
|
@@ -245,6 +604,27 @@ async def sync_pull_requests(
|
|
|
245
604
|
record.pr_number,
|
|
246
605
|
ctx.project_id,
|
|
247
606
|
)
|
|
607
|
+
await _publish_review_requests(pr_obj, ctx.project_id)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
async def _publish_review_requests(
|
|
611
|
+
pr: dict[str, typing.Any], project_id: str
|
|
612
|
+
) -> None:
|
|
613
|
+
"""Record the PR's pending review requests, best-effort."""
|
|
614
|
+
requests = _review_requests_record(pr, project_id=project_id)
|
|
615
|
+
if requests is None:
|
|
616
|
+
return
|
|
617
|
+
try:
|
|
618
|
+
await iggy.publish(
|
|
619
|
+
'pull_request_review_requests', 'github', [requests]
|
|
620
|
+
)
|
|
621
|
+
except Exception:
|
|
622
|
+
LOGGER.exception(
|
|
623
|
+
'github-pr-sync: failed to record review requests for PR %s '
|
|
624
|
+
'of project %s',
|
|
625
|
+
requests.pr_id,
|
|
626
|
+
project_id,
|
|
627
|
+
)
|
|
248
628
|
|
|
249
629
|
|
|
250
630
|
sync_pull_requests_descriptor = ActionDescriptor(
|
|
@@ -252,7 +632,8 @@ sync_pull_requests_descriptor = ActionDescriptor(
|
|
|
252
632
|
label='Sync Pull Request History',
|
|
253
633
|
description=(
|
|
254
634
|
'Record pull_request webhook events (opened, closed, reopened, '
|
|
255
|
-
'synchronize) directly
|
|
635
|
+
'synchronize, review_requested, review_request_removed) directly '
|
|
636
|
+
'into the ClickHouse pull_requests table.'
|
|
256
637
|
),
|
|
257
638
|
callable=typing.cast(
|
|
258
639
|
'typing.Any',
|
|
@@ -265,6 +646,86 @@ sync_pull_requests_descriptor = ActionDescriptor(
|
|
|
265
646
|
)
|
|
266
647
|
|
|
267
648
|
|
|
649
|
+
class SyncPRReviewsConfig(pydantic.BaseModel):
|
|
650
|
+
"""``WebhookRule.handler_config`` for ``sync_pull_request_reviews``."""
|
|
651
|
+
|
|
652
|
+
action_selector: JsonPointer = pydantic.Field(
|
|
653
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/action')
|
|
654
|
+
)
|
|
655
|
+
review_selector: JsonPointer = pydantic.Field(
|
|
656
|
+
default_factory=lambda: jsonpointer.JsonPointer('/payload/review')
|
|
657
|
+
)
|
|
658
|
+
pr_selector: JsonPointer = pydantic.Field(
|
|
659
|
+
default_factory=lambda: jsonpointer.JsonPointer(
|
|
660
|
+
'/payload/pull_request'
|
|
661
|
+
)
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
async def sync_pull_request_reviews(
|
|
666
|
+
*,
|
|
667
|
+
ctx: PluginContext,
|
|
668
|
+
credentials: dict[str, str],
|
|
669
|
+
external_identifier: str,
|
|
670
|
+
action_config: SyncPRReviewsConfig,
|
|
671
|
+
event: object,
|
|
672
|
+
) -> None:
|
|
673
|
+
"""Record a ``pull_request_review`` delivery in ``pull_request_reviews``.
|
|
674
|
+
|
|
675
|
+
Handles ``submitted``, ``edited``, and ``dismissed``. A dismissal
|
|
676
|
+
carries the dismissed review with state ``dismissed``, which
|
|
677
|
+
replaces that review's row. GitHub drops a reviewer from the PR's
|
|
678
|
+
requested reviewers when they submit, so the payload's request
|
|
679
|
+
lists are recorded too; otherwise the reviewer would still read as
|
|
680
|
+
requested. The PR row itself is not touched: the PR object in a
|
|
681
|
+
review payload has no diff stats, so writing it would zero them.
|
|
682
|
+
"""
|
|
683
|
+
del credentials, external_identifier
|
|
684
|
+
action = _resolve(action_config.action_selector, event)
|
|
685
|
+
if not isinstance(action, str) or action not in _REVIEW_ACTIONS:
|
|
686
|
+
return
|
|
687
|
+
review_raw = _resolve(action_config.review_selector, event)
|
|
688
|
+
pr_raw = _resolve(action_config.pr_selector, event)
|
|
689
|
+
if not isinstance(review_raw, dict) or not isinstance(pr_raw, dict):
|
|
690
|
+
return
|
|
691
|
+
pr_obj = typing.cast('dict[str, typing.Any]', pr_raw)
|
|
692
|
+
if not pr_obj.get('id'):
|
|
693
|
+
return
|
|
694
|
+
record = _review_record(
|
|
695
|
+
typing.cast('dict[str, typing.Any]', review_raw),
|
|
696
|
+
project_id=ctx.project_id,
|
|
697
|
+
pr_id=str(pr_obj['id']),
|
|
698
|
+
)
|
|
699
|
+
if record is not None:
|
|
700
|
+
try:
|
|
701
|
+
await iggy.publish('pull_request_reviews', 'github', [record])
|
|
702
|
+
except Exception:
|
|
703
|
+
LOGGER.exception(
|
|
704
|
+
'github-pr-sync: failed to record review %s for project %s',
|
|
705
|
+
record.review_id,
|
|
706
|
+
ctx.project_id,
|
|
707
|
+
)
|
|
708
|
+
await _publish_review_requests(pr_obj, ctx.project_id)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
sync_pull_request_reviews_descriptor = ActionDescriptor(
|
|
712
|
+
name='sync_pull_request_reviews',
|
|
713
|
+
label='Sync Pull Request Reviews',
|
|
714
|
+
description=(
|
|
715
|
+
'Record pull_request_review webhook events (submitted, edited, '
|
|
716
|
+
'dismissed) into the ClickHouse pull_request_reviews table.'
|
|
717
|
+
),
|
|
718
|
+
callable=typing.cast(
|
|
719
|
+
'typing.Any',
|
|
720
|
+
'imbi.plugins.github.pull_requests:sync_pull_request_reviews',
|
|
721
|
+
),
|
|
722
|
+
config_model=typing.cast(
|
|
723
|
+
'typing.Any',
|
|
724
|
+
'imbi.plugins.github.pull_requests:SyncPRReviewsConfig',
|
|
725
|
+
),
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
|
|
268
729
|
class GitHubPullRequestSync(PullRequestSyncCapability):
|
|
269
730
|
"""Pull-request history sync capability handler.
|
|
270
731
|
|
|
@@ -310,9 +771,25 @@ class GitHubPullRequestSync(PullRequestSyncCapability):
|
|
|
310
771
|
PAT-or-App resolution the webhook actions use.
|
|
311
772
|
|
|
312
773
|
Walks every PR in the repo (``state=all``, paginated) and upserts
|
|
313
|
-
into the ClickHouse ``pull_requests`` table
|
|
314
|
-
|
|
315
|
-
|
|
774
|
+
into the ClickHouse ``pull_requests`` table, records the review
|
|
775
|
+
requests and reviews of every open PR (best-effort, after the PR
|
|
776
|
+
rows are published), then reconciles: it
|
|
777
|
+
deletes the project's ``open`` rows on this host whose ``pr_id``
|
|
778
|
+
GitHub did not return, so a PR filed under the project from
|
|
779
|
+
another repository doesn't stay open forever. Closed and merged
|
|
780
|
+
rows are never deleted, and neither are rows from another host
|
|
781
|
+
(a second pr-sync Integration) or rows recorded less than
|
|
782
|
+
``_PRUNE_MARGIN`` before the sync started. A PR GitHub returned
|
|
783
|
+
with an ``id`` keeps its row even when it is too malformed to
|
|
784
|
+
record. The rows deleted are logged.
|
|
785
|
+
|
|
786
|
+
The reconcile is skipped when the listing may be incomplete (a
|
|
787
|
+
404 on any page, a next link it can't follow, or the page cap),
|
|
788
|
+
when the GitHub App is not installed, and when the repo did not
|
|
789
|
+
come from the project's repository link (the Integration's link
|
|
790
|
+
key or ``github-repository``). It runs whether or not the
|
|
791
|
+
publish succeeded, and a ClickHouse failure is logged and
|
|
792
|
+
swallowed.
|
|
316
793
|
|
|
317
794
|
``additions``, ``deletions``, and ``changed_files`` are ``0`` for
|
|
318
795
|
backfill rows (GitHub's list-PRs API omits them); they are
|
|
@@ -338,7 +815,8 @@ class GitHubPullRequestSync(PullRequestSyncCapability):
|
|
|
338
815
|
'on-demand sync: set the Integration flavor/host'
|
|
339
816
|
)
|
|
340
817
|
base = host_to_api_base(host)
|
|
341
|
-
|
|
818
|
+
target = resolve_repository_target(ctx, host, _SELF_SLUG)
|
|
819
|
+
owner, repo = target.owner, target.repo
|
|
342
820
|
try:
|
|
343
821
|
token = await _resolve_bearer(credentials, base, owner, repo)
|
|
344
822
|
except AppNotInstalledError as exc:
|
|
@@ -348,15 +826,44 @@ class GitHubPullRequestSync(PullRequestSyncCapability):
|
|
|
348
826
|
ctx.project_id,
|
|
349
827
|
)
|
|
350
828
|
return 0
|
|
829
|
+
started_at = datetime.datetime.now(datetime.UTC)
|
|
351
830
|
async with _client(base, owner, repo, token) as client:
|
|
352
|
-
raw_prs = await _fetch_all_prs(
|
|
831
|
+
raw_prs, complete = await _fetch_all_prs(
|
|
353
832
|
client, max_wait=_BACKFILL_MAX_WAIT_SECONDS
|
|
354
833
|
)
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
834
|
+
records: list[pydantic.BaseModel] = []
|
|
835
|
+
requests: list[pydantic.BaseModel] = []
|
|
836
|
+
for pr in raw_prs:
|
|
837
|
+
record = _pr_record(pr, project_id=ctx.project_id)
|
|
838
|
+
if record is not None:
|
|
839
|
+
records.append(record)
|
|
840
|
+
if pr.get('state') == 'open':
|
|
841
|
+
request = _review_requests_record(
|
|
842
|
+
pr, project_id=ctx.project_id
|
|
843
|
+
)
|
|
844
|
+
if request is not None:
|
|
845
|
+
requests.append(request)
|
|
846
|
+
# PRs go out before the review fetches, so a review fetch
|
|
847
|
+
# can never cost the PR rows.
|
|
848
|
+
recorded = await _publish_best_effort(
|
|
849
|
+
'pull_requests', records, ctx.project_id
|
|
850
|
+
)
|
|
851
|
+
await _publish_best_effort(
|
|
852
|
+
'pull_request_review_requests', requests, ctx.project_id
|
|
853
|
+
)
|
|
854
|
+
reviews = await _open_pr_reviews(
|
|
855
|
+
client,
|
|
856
|
+
raw_prs,
|
|
857
|
+
project_id=ctx.project_id,
|
|
858
|
+
max_wait=_BACKFILL_MAX_WAIT_SECONDS,
|
|
859
|
+
)
|
|
860
|
+
await _publish_best_effort(
|
|
861
|
+
'pull_request_reviews', reviews, ctx.project_id
|
|
862
|
+
)
|
|
863
|
+
# Only the project's repository link is authoritative enough to
|
|
864
|
+
# delete against; another same-host link or a project-type/slug
|
|
865
|
+
# guess may name some other repo entirely.
|
|
866
|
+
if complete and _is_repository_link(ctx, host, target):
|
|
867
|
+
pr_ids = [str(pr['id']) for pr in raw_prs if pr.get('id')]
|
|
868
|
+
await _prune_stale_prs(ctx.project_id, host, started_at, pr_ids)
|
|
869
|
+
return recorded
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: imbi-plugin-github
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.44.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.44.0
|
|
16
16
|
Requires-Dist: pydantic>=2
|
|
17
17
|
Requires-Dist: pyjwt[crypto]>=2.8
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
@@ -107,6 +107,35 @@ credential in one of two modes, resolved per call:
|
|
|
107
107
|
from the pushed repository (`GET /repos/{owner}/{repo}/installation`).
|
|
108
108
|
The App needs **Contents: Read-only**.
|
|
109
109
|
|
|
110
|
+
### Pull request history sync
|
|
111
|
+
|
|
112
|
+
`GitHubPullRequestSync.sync_all_history` is the on-demand `pr-sync`
|
|
113
|
+
backfill. It walks `GET /repos/{owner}/{repo}/pulls?state=all` (up to 100
|
|
114
|
+
pages of 100) and publishes a `pull_requests` row for every PR.
|
|
115
|
+
|
|
116
|
+
After a complete walk it reconciles: it deletes the project's `open` rows
|
|
117
|
+
on the synced host whose `pr_id` GitHub did not list, so a PR filed under
|
|
118
|
+
the project from another repository does not stay `open` forever. It
|
|
119
|
+
selects those rows first, logs their URLs, and deletes exactly those
|
|
120
|
+
`pr_id`s; a sync with nothing stale issues no delete.
|
|
121
|
+
|
|
122
|
+
It never deletes:
|
|
123
|
+
|
|
124
|
+
- closed or merged rows, so PR history survives a move to a new
|
|
125
|
+
repository;
|
|
126
|
+
- rows on another host, from a second `pr-sync` Integration;
|
|
127
|
+
- rows recorded less than five minutes before the sync started, which
|
|
128
|
+
covers webhook rows written during the sync and clock skew between
|
|
129
|
+
hosts;
|
|
130
|
+
- the row of a PR GitHub lists but is too malformed to record.
|
|
131
|
+
|
|
132
|
+
The reconcile is skipped when the listing may be incomplete (a 404 on any
|
|
133
|
+
page, a next link it cannot follow, or the page cap), when the GitHub App
|
|
134
|
+
is not installed for the repository, and when the repository did not come
|
|
135
|
+
from the project's repository link (the Integration's link key or
|
|
136
|
+
`github-repository`). It runs even if the publish failed, and a
|
|
137
|
+
ClickHouse failure is logged and does not fail the sync.
|
|
138
|
+
|
|
110
139
|
## Integration options
|
|
111
140
|
|
|
112
141
|
Asked once per Integration and delivered to every capability on
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
imbi/plugins/github/README.md,sha256=
|
|
1
|
+
imbi/plugins/github/README.md,sha256=WF_NszzEcKoCMZ6SmB2mgQxkonVwlMbla5Dl0N3B4mU,7355
|
|
2
2
|
imbi/plugins/github/__init__.py,sha256=L_5_kr22VkjJob6SVLVzCkIvnWddxi54GIrM2cNs2rg,441
|
|
3
3
|
imbi/plugins/github/_app_auth.py,sha256=qr3azBExZSI0vDIIKMKV8nSllJNKCJLKBXV86lIp9jE,19015
|
|
4
4
|
imbi/plugins/github/_hosts.py,sha256=sYuE3f7934125VZ6GGTqm8t3sVa3m0DNt3jL9Hb8nM0,4014
|
|
@@ -8,11 +8,11 @@ imbi/plugins/github/deployment.py,sha256=bC5qMvbO-N0f5DTNo7ObyZIUtN4cl8uyjHgpp57
|
|
|
8
8
|
imbi/plugins/github/doctor.py,sha256=sIps9YyDFLva7JHyKJU0IkKSW8tiI-PArC0s1ytxv3w,33177
|
|
9
9
|
imbi/plugins/github/identity.py,sha256=9t_e-_jUbQ1iL0Vb6vaK2d7bT1ki6pw9aWwc4ics6gI,10354
|
|
10
10
|
imbi/plugins/github/lifecycle.py,sha256=KJSzB36zFqetQBY5HNXLsRGRoIwGhrBJ-I1mA8G9H_Q,37711
|
|
11
|
-
imbi/plugins/github/plugin.py,sha256=
|
|
11
|
+
imbi/plugins/github/plugin.py,sha256=LDFCqj6e2jfF9io3U7cC0eVOUAxw1ENvFS-0AWOMuDc,16073
|
|
12
12
|
imbi/plugins/github/public.py,sha256=GZnTLbj_Gl5sHWuRqFuajnoO2xPudNQumAt4h_uJpYw,3114
|
|
13
|
-
imbi/plugins/github/pull_requests.py,sha256=
|
|
13
|
+
imbi/plugins/github/pull_requests.py,sha256=DH9CnJoCpY5Ats5sZ_ktLvy54kBwK9g_-CDMlAIOq7Y,32553
|
|
14
14
|
imbi/plugins/github/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
15
|
imbi/plugins/github/secret_provider.py,sha256=83BQwbG6AuIlyCWAskkN5hrVbRc9X5UqQJl5qt7Pij0,27317
|
|
16
|
-
imbi_plugin_github-2.
|
|
17
|
-
imbi_plugin_github-2.
|
|
18
|
-
imbi_plugin_github-2.
|
|
16
|
+
imbi_plugin_github-2.44.0.dist-info/METADATA,sha256=o6mVXl-pbwOEL5FSJnvFz8dQo292FnGptle6Rj57fT0,8977
|
|
17
|
+
imbi_plugin_github-2.44.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
18
|
+
imbi_plugin_github-2.44.0.dist-info/RECORD,,
|
|
File without changes
|