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.
@@ -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);
@@ -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 PullRequestRecord
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 resolve_owner_repo
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
- _SYNC_ACTIONS = frozenset({'opened', 'closed', 'reopened', 'synchronize'})
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
- async def _fetch_all_prs(
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?state=all`` and return every PR object.
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
- break
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
- return out
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``, and ``synchronize``
216
- actions. Other actions (e.g. ``labeled``, ``review_requested``) are
217
- silently ignored -- the rule's CEL filter can pre-screen them, but
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 into the ClickHouse pull_requests table.'
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
- ``ReplacingMergeTree`` dedupes against rows the webhook already
315
- recorded, so re-running is safe.
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
- owner, repo = resolve_owner_repo(ctx, host, _SELF_SLUG)
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
- records: list[pydantic.BaseModel] = []
356
- for pr in raw_prs:
357
- record = _pr_record(pr, project_id=ctx.project_id)
358
- if record is not None:
359
- records.append(record)
360
- return await _publish_best_effort(
361
- 'pull_requests', records, ctx.project_id
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.43.0
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.43.0
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=HUD61Mvn9fSykQ4I-oUoZ62wd9tdhJ8jqoaPnpuPasM,7121
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=KmAu1lrGX2tkaNqTd3GV9DwLaPd78ZrODGi6jUt2Z0c,15981
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=FiHbjdGqghJ2Q1--d15XfOtpUe_KZE7hYRb8JLQ5U1I,13510
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.43.0.dist-info/METADATA,sha256=IJBROVQR2tagTrCI0PddNXFWxlbGmcPZSkieQLVPQbQ,7618
17
- imbi_plugin_github-2.43.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
18
- imbi_plugin_github-2.43.0.dist-info/RECORD,,
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,,