"""邀请生命周期 —— 从「发出去了」到「进组了 / 过期了」。 GitHub 的组织邀请 7 天过期,而**它不会告诉任何人**:没有 webhook, 过期时也不留 `failed_reason`。所以 Helix 台账里那些 ``pending_invite`` 如果没人管,会永远停在那里 —— 界面上写着「已邀请」,实际上那个人早就 错过了,而且没有任何地方提示他。 这个模块靠对账把它变回真实: 1. 拉这个组织当前的**活跃成员**与**待接受邀请**; 2. 台账里 pending 而 GitHub 上已是成员 → 翻 ``active``(他点了接受); 3. 台账里 pending、GitHub 上既不是成员也没有待接受邀请 → 只有两种可能: 本人拒绝了,或者过了 7 天。GitHub 不区分这两者,所以按**时间**判: 超过 TTL 记 ``expired``(可重发),否则记 ``declined``(不可重发)。 把两者合并会让一个明确拒绝过的人被反复重发邀请。 4. ``expired`` 的自动重发一次,并通知本人 —— 绝大多数过期不是拒绝, 是没看到。 **只处理 pending,不碰 active。** 「GitHub 上有、Helix 台账里没有」和 「Helix 说 active 而 GitHub 说不在」是对账(PR6)要答的问题,涉及是否 该在台账上删东西,需要单独的谨慎。这里只推进自己发出去的那些邀请。 """ from __future__ import annotations import logging from datetime import UTC, datetime from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.core.txn import best_effort from app.identity.person_models import IdentityPerson from . import constants as const from . import service as svc from .driver_access import resolve_org_driver from .models import GithubOrg, GithubOrgMembership logger = logging.getLogger(__name__) #: 一次 tick 最多重发多少张邀请。上限存在的理由不是性能,是**爆炸半径**: #: 一次误判(例如凭证临时读不到成员列表)如果能重发几百张,那就是几百封 #: 发给真人的邮件。触顶时会 warning,不静默截断。 MAX_RESENDS_PER_RUN = 50 def _now() -> datetime: return datetime.now(UTC) def _naive(dt: datetime) -> datetime: return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt async def reconcile_org_invitations( db: AsyncSession, org: GithubOrg, *, trace_id: str, ) -> dict[str, Any]: """把一个组织的待接受邀请推进到真实状态。""" summary: dict[str, Any] = { "org": org.slug, "checked": 0, "accepted": 0, "expired": 0, "declined": 0, "resent": 0, "resend_failed": 0, } resolution = await resolve_org_driver(db, org) if not resolution.automatic: summary["skipped"] = resolution.reason return summary driver = resolution.driver assert driver is not None # noqa: S101 pending = list( ( await db.execute( select(GithubOrgMembership).where( GithubOrgMembership.org_id == org.id, # 只看**真的发出过邀请**的那些。``awaiting_manual`` # 从来没有邀请可推进 —— 按时间去判一条不存在的邀请, # 结果只会是把它误判成「本人拒绝」。 GithubOrgMembership.state == const.MEMBERSHIP_STATE_PENDING_INVITE, ) ) ).scalars().all() ) summary["checked"] = len(pending) if not pending: return summary # 一次性拉两份清单,而不是逐个查 —— 一个组织可能有几十张待接受 # 邀请,逐个查就是几十次 API 调用外加限流。 members = {m.login.lower() for m in await driver.list_members(org.slug)} invited = { i.login.lower(): i for i in await driver.list_pending_invitations(org.slug) if i.login } audit = AuditService(db) now = _now() resends = 0 for row in pending: login = row.github_login.lower() if login in members: await svc.upsert_membership( db, org=org, github_login=row.github_login, person_id=row.person_id, state=const.MEMBERSHIP_STATE_ACTIVE, source=row.source, actor_kind="integration", trace_id=trace_id, event_type=const.EVENT_JOINED, ) summary["accepted"] += 1 continue if login in invited: # 邀请还挂着,没到期。什么都不做 —— 这是正常的等待,不是异常。 continue # GitHub 上既不是成员、也没有待接受邀请。只有两种可能,而 GitHub # 不区分它们,所以按时间判。 expires = row.invitation_expires_at aged_out = ( expires is not None and _naive(expires) <= _naive(now) ) if aged_out: await svc.upsert_membership( db, org=org, github_login=row.github_login, person_id=row.person_id, state=const.MEMBERSHIP_STATE_EXPIRED, source=row.source, actor_kind="integration", trace_id=trace_id, event_type=const.EVENT_EXPIRED, ) summary["expired"] += 1 if resends >= MAX_RESENDS_PER_RUN: continue resends += 1 result = await driver.invite_member( org.slug, row.github_login, role=row.role, ) if result.ok: await svc.upsert_membership( db, org=org, github_login=row.github_login, person_id=row.person_id, state=( const.MEMBERSHIP_STATE_ACTIVE if result.already_done else const.MEMBERSHIP_STATE_PENDING_INVITE ), source=row.source, actor_kind="integration", trace_id=trace_id, event_type=const.EVENT_REINVITED, ) summary["resent"] += 1 await _notify_resent(db, row=row, org=org) else: summary["resend_failed"] += 1 await audit.log( trace_id=trace_id, actor_type="integration", action="github_org.invite.resend", status="failure", resource_type="github_org_membership", resource_id=row.id, detail={ "org": org.slug, "github_login": row.github_login, "error": result.error_message, }, ) else: # 没到期却已经不在待接受列表里 = 本人明确拒绝了。**不重发** —— # 反复给一个拒绝过的人发邀请是骚扰,不是服务。 await svc.upsert_membership( db, org=org, github_login=row.github_login, person_id=row.person_id, state=const.MEMBERSHIP_STATE_DECLINED, source=row.source, actor_kind="integration", trace_id=trace_id, event_type=const.EVENT_DECLINED, ) summary["declined"] += 1 if resends >= MAX_RESENDS_PER_RUN: logger.warning( "github_org: hit the %d-resend cap for %s — the rest will be " "retried on the next tick", MAX_RESENDS_PER_RUN, org.slug, ) summary["resend_capped"] = True org.last_reconciled_at = _naive(now) org.last_reconcile_error = None return summary async def _notify_resent( db: AsyncSession, *, row: GithubOrgMembership, org: GithubOrg, ) -> None: """重发之后告诉本人 —— 没这一步,重发只是换了一张同样没人看到的邀请。""" if not row.person_id: return person = await db.get(IdentityPerson, row.person_id) if person is None or person.employment_status != "active": return try: from app.notifications.service import ( # noqa: PLC0415 NotificationEnvelope, NotificationService, ) await NotificationService(db).dispatch( NotificationEnvelope( recipient=person, title=f"GitHub 组织邀请已重发:{org.slug}", body_text=( f"你加入 {org.slug} 的邀请上次没在 " f"{const.GITHUB_INVITATION_TTL_DAYS} 天内被接受,已自动重发到 " f"GitHub 账号 {row.github_login}。\n\n" f"请打开 https://github.com/{org.slug} 接受邀请。" ), template_key="github_org_invite_resent", url=f"https://github.com/{org.slug}", context={ "org_slug": org.slug, "github_login": row.github_login, }, card_subtitle="上一封过期了,这是重发的", related_resource_type="github_org", ) ) except Exception as e: # noqa: BLE001 logger.warning("github_org: resend notification failed: %s", e) async def reconcile_all_invitations( db: AsyncSession, *, trace_id: str, ) -> dict[str, Any]: """对每个启用的组织跑一遍。 单个组织失败**不中断其余** —— 一个凭证坏掉的组织不该让另外四个的 邀请也停止推进。失败记在那个组织的 ``last_reconcile_error`` 上, 这样「哪个组织在出问题」在登记表上就看得见。 """ orgs = await svc.list_orgs(db) totals: dict[str, Any] = { "orgs": len(orgs), "accepted": 0, "expired": 0, "declined": 0, "resent": 0, "resend_failed": 0, "failed_orgs": [], } for org in orgs: # 每个组织一个 SAVEPOINT。没有它,「单个失败不中断其余」只对纯 # Python 异常成立 —— 而真实的故障是 **flush 失败**:那之后 session # 被毒化,后面每个组织都撞 PendingRollbackError,连 # ``last_reconcile_error`` 都提交不上去,整个 job 以一条与真因无关 # 的错误收场,真因只留在日志里。 # # 用 ``best_effort`` 而不是裸 ``begin_nested()``:锁等待 / 死锁 # (1205/1213/1305)和连接断开(2006/2013)时事务在服务端就已经 # 没了,savepoint 回滚救不回来,那两类必须**往上抛**让 cron 重跑, # 而不是被记成这个组织的「对账错误」然后继续对着一个死事务循环。 # 它顺带把失败**归到正确的组织头上**:退出 savepoint 会 flush, # 一条延迟到下一个组织才炸的 INSERT 不会再冤枉别人。 # # ``slug`` 先取出来:回滚 savepoint 会让这个组织上的属性过期, # 而在 async session 上碰一个过期属性是一次同步 IO —— MissingGreenlet, # 不是「再查一次」。要写它得先 ``await db.refresh``。 slug = org.slug summary: dict[str, Any] | None = None async with best_effort( db, label=f"invitation reconcile {slug}", log=logger, log_level=logging.DEBUG, ) as step: summary = await reconcile_org_invitations( db, org, trace_id=trace_id, ) # ``step.ok``,不是 ``summary is None``:body 正常返回了,失败发生在 # 退出 savepoint 时的那次 flush —— 变量早就被赋上值了。 if not step.ok or summary is None: # ERROR + 栈:cron 的失败只有日志这一个现场。 logger.error( "github_org: invitation reconcile failed for %s: %s", slug, step.reason(), exc_info=step.error, ) await db.refresh(org) org.last_reconcile_error = step.reason() totals["failed_orgs"].append(slug) continue for key in ("accepted", "expired", "declined", "resent", "resend_failed"): totals[key] += summary.get(key, 0) return totals