"""GitHub 组织域的定时任务。 三个 key:``github_org.invitation_reconcile`` / ``github_org.member_reconcile`` / ``github_org.governance_digest``。 **为什么必须有它**:GitHub 的组织邀请 7 天过期,而它不发 webhook、 过期时也不留痕。没有这个 cron,台账里的 ``pending_invite`` 会永远停在 那里 —— 界面上写着「已邀请」,而那个人早就错过了,还没有任何地方提示他。 handler 失败必须 **raise**:平台的失败通知靠异常触发,吞掉异常等于让 一个坏掉的 cron 每次都记成成功。单个组织的失败在 service 层已经被隔离 (记进那个组织的 ``last_reconcile_error``),这里抛的是整体性的故障。 """ from __future__ import annotations import logging import uuid from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.domains.github_org.constants import SEAT_WARN_THRESHOLD_KEY from app.domains.github_org.invitation_lifecycle import ( reconcile_all_invitations, ) from app.scheduled_jobs.registry import register_scheduled_job logger = logging.getLogger(__name__) @register_scheduled_job( "github_org.invitation_reconcile", display_name="GitHub 组织邀请对账", description=( "把台账里「已邀请」的记录推进到真实状态:对方接受了就翻 active," "超过 7 天没接受记 expired 并自动重发一次(并通知本人)," "没到期却已不在待接受列表里记 declined(明确拒绝,不重发)。\n" "为什么需要它:GitHub 的组织邀请 7 天过期,而它不发 webhook、" "过期时也不留痕 —— 没有这次对账,一张过期的邀请在 Helix 里会" "永远显示成「已邀请」,而本人早就错过了。" ), ) async def invitation_reconcile( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: trace_id = f"github_org.invitation_reconcile:{uuid.uuid4()}" result = await reconcile_all_invitations(db, trace_id=trace_id) await db.commit() return result @register_scheduled_job( "github_org.member_reconcile", display_name="GitHub 组织成员对账", description=( "把台账与 GitHub 上真实的成员名单对齐,两个方向都对:\n" "① GitHub 上有、Helix 没有 —— 有人绕过 Helix 直接被加进了组织。" "这不是错误(org owner 本来就能直接点),但一条 Helix 不知道的" "成员资格,离职时不会被回收,所以必须被看见。\n" "② Helix 说 active、GitHub 上没有 —— 有人在 GitHub 上被直接移除了," "台账要跟上,否则它会一直宣称一个已经不存在的访问权。\n" "顺带做两件小事:回填 person_id(台账里认不出是谁的行,如果那个 " "handle 现在能对上某人的 GitHub ID 了就接上 —— 但这只救得了以后" "补登记的人,存量那批要靠治理面的 AI 建议清);以及刷一遍坐席用量," "凭证和组织清单与对账完全相同,不值得单开一个 job。\n" "带一道空列表刹车:GitHub 说 0 个成员而台账里有 ≥3 条活着的记录时" "**拒绝对账** —— 一次组织改名或权限被撤会长得和「所有人都走了」" "一模一样,而那张表正是离职回收读的。" ), weight="heavy", ) async def member_reconcile( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.github_org.reconcile import ( # noqa: PLC0415 reconcile_all, ) trace_id = f"github_org.member_reconcile:{uuid.uuid4()}" result = await reconcile_all(db, trace_id=trace_id) await db.commit() return result @register_scheduled_job( "github_org.governance_digest", display_name="GitHub 组织治理摘要", description=( "每周一次,把 GitHub 组织的治理待办推给 github_admin / global_admin:" "离职了还在组织里、坐席快用完(带可直接加坐席的账单页链接)、" "批了但还没发出邀请。\n" "为什么需要它:这些事上线第一天就都真实存在(一个离职的人还在 tikv 里," "pingcap-inc 只剩 3 个坐席),而它们只活在台账和一张 KPI 卡上 —— " "没有任何东西会去告诉该处理的人。「界面上有个数」离「有人看到」还差" "一步:得有人主动打开那个页面。\n" "**没有待办时什么都不发。** 一封每周固定到达、写着「一切正常」的信," "第三周就会被规则归档,第四周真出事时它也一起被归档了。\n" "「还没认出是谁」只作为背景数字出现,不计入催办 —— 那是一个几百条的" "核对任务,靠界面上的 AI 建议分批清;把三位数放进本周待办,前面那条" "已经发生的权限暴露就被淹掉了。\n" '坐席告警阈值只有一个来源:平台设置 github_org.seat_warn_threshold' '(在「平台设置」页改),界面待办与这封摘要读同一个值。' '这里的 config **不再**接受 seat_warn_threshold —— 填了会直接报错,' '而不是安静地只覆写这封摘要。' ), ) async def governance_digest( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.github_org.digest import ( # noqa: PLC0415 send_governance_digest, ) if (config or {}).get("seat_warn_threshold") is not None: # 这把「只覆写周报那一侧」的钥匙曾经存在,而且它**唯一一次真实 # 生效就是错的**:583 号迁移把 {"seat_warn_threshold": 10} 播种进 # 了这一行,#3391 把阈值抬成平台设置(生产设成 1)时没有取走它, # 于是首播那天生产按 10 报了一个余 4 的组织 —— 平台设置页写着 1、 # 界面待办按 1 算(不报),而这封信按 10 发了出去。**改设置没改 # 行为,正是这个域反复在消的那种坑。** # # 所以现在填了就抛,而不是安静忽略:一个被安静忽略的 config 键, # 表现同样是「我改了配置但行为没变」,只能靠读源码发现。抛出去 # 会立刻触发平台的失败通知,消息里直接给出该改的地方。 raise ValueError( "seat_warn_threshold is no longer a job config key — " "set the platform setting " f"{SEAT_WARN_THRESHOLD_KEY!r} instead, so the weekly digest " "and the in-app action list cannot disagree", ) trace_id = f"github_org.governance_digest:{uuid.uuid4()}" result = await send_governance_digest(db, trace_id=trace_id) # send_governance_digest 内部按人提交;这里再提一次兜住「一条都没发」 # 那条路径上可能残留的读事务。 await db.commit() return result