"""治理待办 —— 把「有问题」变成「有人能点」。 这个模块存在的理由,是上线第一天的实测:``leavers_still_in_orgs`` 是 **1** (一个已离职的人还在 tikv 里),而那个 1 只活在一张 KPI 卡和一条审计行里。 **没有任何东西会去提醒该处理它的人,也没有任何地方能点一下把它处理掉。** 一个说得出问题但处理不了问题的界面,是把工作推回给人而不是替人做掉。 四类待办,各自有明确的「谁该动、动什么」: | 类别 | 是什么 | 谁该动 | 界面上能做什么 | |---|---|---|---| | ``leaver_still_member`` | 已离职却仍在不豁免的组织里 | GitHub 管理员 | **一键移除** | | ``seat_low`` | 坐席快用完 | org owner / billing manager | 点开账单页加坐席 | | ``awaiting_manual`` | 批了但没配凭证,邀请等人去点 | GitHub 管理员 / IT | 点开组织页手工邀请,或配好凭证后重试 | | ``unlinked`` | GitHub 上有、还没认出是谁 | GitHub 管理员 | **关联到人**,或**确认是外部人员**(两者都可用 AI 建议) | ``unlinked`` 那一格里的「或」是这套东西能不能用下去的关键。这些是开源组织, 里面本来就有大量外部贡献者 —— 没有「确认是外部人员」这条出路,那个数字 永远不会归零,而一个永远不归零的待办数等于没有待办数。 刻意**不**在这里放「邀请待接受」——那是正常等待,不是待办。把它列进来会让 这个清单长期非空,而一个长期非空的待办清单等于没有待办清单。 """ from __future__ import annotations import logging from datetime import UTC, datetime from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.identity.person_models import IdentityPerson from . import constants as const from .models import GithubOrg, GithubOrgMembership logger = logging.getLogger(__name__) KIND_LEAVER = "leaver_still_member" KIND_SEAT_LOW = "seat_low" KIND_AWAITING_MANUAL = "awaiting_manual" KIND_UNLINKED = "unlinked" #: 按「不处理的后果」排序,不按数量排。一个已离职的人还有私有仓库访问权, #: 比 300 条认不出是谁严重得多 —— 后者是覆盖率问题,前者是已经发生的暴露。 KIND_ORDER = (KIND_LEAVER, KIND_SEAT_LOW, KIND_AWAITING_MANUAL, KIND_UNLINKED) SEVERITY_BY_KIND = { KIND_LEAVER: "critical", KIND_SEAT_LOW: "warning", KIND_AWAITING_MANUAL: "warning", KIND_UNLINKED: "info", } def _now() -> datetime: return datetime.now(UTC) def _iso(v: Any) -> str | None: return v.isoformat() if v is not None else None async def leavers_still_in_orgs( db: AsyncSession, *, limit: int = 500, ) -> list[dict[str, Any]]: """已离职 / 待离职,却仍在某个**不豁免**组织里的成员资格。 这套自动化的全部意义就是这个清单应该是空的。它非空有两条真实路径, **而且都绕开了离职流程**:有人在 GitHub 上手工把离职的人加了回去; 或者这条成员资格在他离职时 ``person_id`` 还是空的(认不出是谁), 离职扫描根本看不到它 —— 后来对账回填了 person_id,人已经走了。 """ rows = list( ( await db.execute( select(GithubOrgMembership, GithubOrg, IdentityPerson) .join(GithubOrg, GithubOrg.id == GithubOrgMembership.org_id) .join( IdentityPerson, IdentityPerson.id == GithubOrgMembership.person_id, ) .where( GithubOrgMembership.state.in_(const.MEMBERSHIP_LIVE_STATES), GithubOrg.deleted_at.is_(None), GithubOrg.offboarding_policy != const.OFFBOARDING_POLICY_EXEMPT, IdentityPerson.employment_status.in_( ("left", "leaver_pending"), ), ) .order_by(IdentityPerson.work_email, GithubOrg.slug) .limit(limit) ) ).all() ) return [ { "membership_id": m.id, "org_id": org.id, "org_slug": org.slug, "org_html_url": f"https://github.com/{org.slug}", "github_login": m.github_login, "github_url": f"https://github.com/{m.github_login}", "person_id": str(p.id), "person_label": p.display_name or p.work_email, "person_email": p.work_email, "employment_status": p.employment_status, "state": m.state, "source": m.source, "joined_at": _iso(m.joined_at), # 「他是什么时候走的」决定这条待办有多急。直接访问而不是 # getattr 带默认值:``termination_date`` 是一个映射列,加默认值 # 只会在将来改名时把它静默读成 None。 "termination_date": _iso(p.termination_date), } for m, org, p in rows ] async def seat_threshold_value(db: AsyncSession) -> int: """现在生效的坐席告警阈值。 优先级:调用方显式传的 > 平台设置 ``github_org.seat_warn_threshold`` > 代码默认 :data:`constants.SEAT_WARN_THRESHOLD`。 为什么要有中间这一层:这个阈值有**两个**消费者 —— 每周的治理摘要和 界面上的待办清单。定时任务的 ``config`` 早就能覆写摘要那一侧,而界面 那一侧读的是常量,于是「改了阈值」的结果是**周报不报、界面还报**。 一个问题两个界面两个答案,是这个域今天刚为 bot 策略消掉的同一种坑。 读不出整数就退回默认值并留一条日志 —— 一个被写成 ``"ten"`` 的设置 静默生效成 0 的后果是这份清单永久为空,而那看起来和「一切正常」 一模一样。 """ from app.integrations.service import get_platform_setting # noqa: PLC0415 raw = await get_platform_setting( const.SEAT_WARN_THRESHOLD_KEY, default=None, session=db, ) if raw is None: return const.SEAT_WARN_THRESHOLD try: val = int(raw) except (TypeError, ValueError): logger.warning( "github_org: %s is not an integer (%r) — falling back to %s", const.SEAT_WARN_THRESHOLD_KEY, raw, const.SEAT_WARN_THRESHOLD, ) return const.SEAT_WARN_THRESHOLD if val < 0: # 负数阈值让清单永久为空。它比「阈值太低」更坏:低阈值至少在 # 用满那一刻还会响一声。 logger.warning( "github_org: %s is negative (%s) — falling back to %s", const.SEAT_WARN_THRESHOLD_KEY, val, const.SEAT_WARN_THRESHOLD, ) return const.SEAT_WARN_THRESHOLD return val async def seat_pressure( db: AsyncSession, *, threshold: int | None = None, ) -> list[dict[str, Any]]: """坐席快用完的组织。 只看**有真实上限**的计划。free 计划报 ``seats=92`` 而 ``filled=203``, 按字面算余量 -111 —— 一个永久亮着的假警报;enterprise 报的是整个 enterprise 的池子。把它们算进来,这个清单会永久非空,而一个永久非空 的待办清单等于没有。 """ cap = ( await seat_threshold_value(db) if threshold is None else int(threshold) ) orgs = list( ( await db.execute( select(GithubOrg).where( GithubOrg.deleted_at.is_(None), GithubOrg.is_active.is_(True), ) ) ).scalars().all() ) # 用 (余量, slug) 的元组单独攒排序键,而不是排完再从 dict 里取值: # 那个 dict 的值类型是 ``str | int | None``,靠 ``int()`` 现场转换只是 # 把「这一列一定是数字」的假设从类型系统里挪到运行期。 ranked: list[tuple[int, str, dict[str, Any]]] = [] for org in orgs: remaining = const.seat_remaining( org.seat_plan, org.seat_filled, org.seat_total, ) if remaining is None or remaining > cap: continue ranked.append((remaining, org.slug, { "org_id": org.id, "org_slug": org.slug, "plan": org.seat_plan, "filled": org.seat_filled, "total": org.seat_total, "remaining": remaining, # 通知与界面上那个「可直接操作」的链接。 "billing_url": const.seat_billing_url(org.slug), "checked_at": _iso(org.seat_checked_at), "exhausted": remaining <= 0, }, )) ranked.sort(key=lambda t: (t[0], t[1])) return [row for _, _, row in ranked] async def awaiting_manual( db: AsyncSession, *, limit: int = 500, ) -> list[dict[str, Any]]: """审批批了、但邀请还没发出去的记录(没配凭证时的人工兜底)。""" rows = list( ( await db.execute( select(GithubOrgMembership, GithubOrg) .join(GithubOrg, GithubOrg.id == GithubOrgMembership.org_id) .where( GithubOrgMembership.state == const.MEMBERSHIP_STATE_AWAITING_MANUAL, GithubOrg.deleted_at.is_(None), ) .order_by(GithubOrgMembership.created_at) .limit(limit) ) ).all() ) now = _now().replace(tzinfo=None) out = [] for m, org in rows: created = m.created_at.replace(tzinfo=None) if m.created_at else None out.append( { "membership_id": m.id, "org_id": org.id, "org_slug": org.slug, "org_html_url": f"https://github.com/{org.slug}", "github_login": m.github_login, "person_id": m.person_id, "created_at": _iso(m.created_at), # 等了多久 —— 一条等了两周的人工待办和一条刚落的,紧急程度 # 完全不同,而界面上只显示「等人工」看不出区别。 "waiting_days": ( (now - created).days if created is not None else None ), } ) return out async def unlinked_summary(db: AsyncSession) -> dict[str, Any]: """还没认出是谁的成员资格 —— 只给总数与分组,不给全量清单。 **``total`` 刻意不含已被人确认为外部人员的行。** 上线实测 331 条认不出 是谁,而抽样拉 GitHub 个人资料发现其中相当一部分 company 写着别家公司、 基金会、高校 —— 他们本来就不是员工,永远也「认」不出来。如果把他们 留在 ``total`` 里,这个数永远不会归零,而一个永远不归零的待办数等于 没有待办数:看的人第二周就不看了。 ``external_confirmed`` 单独报,因为它是**进展**:它涨说明有人在清。 """ def _q(external: bool): cond = ( GithubOrgMembership.external_confirmed_at.is_not(None) if external else GithubOrgMembership.external_confirmed_at.is_(None) ) return ( select(GithubOrg.slug, func.count().label("n")) .join( GithubOrgMembership, GithubOrgMembership.org_id == GithubOrg.id, ) .where( GithubOrgMembership.person_id.is_(None), cond, GithubOrgMembership.state.in_(const.MEMBERSHIP_LIVE_STATES), GithubOrg.deleted_at.is_(None), ) .group_by(GithubOrg.slug) .order_by(func.count().desc()) ) rows = list((await db.execute(_q(False))).all()) ext = list((await db.execute(_q(True))).all()) return { "total": sum(int(n) for _, n in rows), "by_org": [{"org_slug": slug, "count": int(n)} for slug, n in rows], "external_confirmed": sum(int(n) for _, n in ext), "external_by_org": [ {"org_slug": slug, "count": int(n)} for slug, n in ext ], } async def collect_action_items( db: AsyncSession, *, seat_threshold: int | None = None, ) -> dict[str, Any]: """一次调用拿到全部治理待办。界面与周报共用它 —— 两份实现迟早会 在「什么算待办」上分叉,而那种分叉的表现是界面上有、周报里没有。""" leavers = await leavers_still_in_orgs(db) cap = ( await seat_threshold_value(db) if seat_threshold is None else int(seat_threshold) ) seats = await seat_pressure(db, threshold=cap) manual = await awaiting_manual(db) unlinked = await unlinked_summary(db) counts = { KIND_LEAVER: len(leavers), KIND_SEAT_LOW: len(seats), KIND_AWAITING_MANUAL: len(manual), KIND_UNLINKED: unlinked["total"], } # 「这周有没有需要人动手的事」。 # # 「认不出是谁」不计入 —— 但**不是**因为它会自己收敛。它不会:实测 # 217 个认不出的 handle 里,归一后与任何企业邮箱前缀字面相同的是 0 个。 # 它不计入是因为它的处理节奏不同:那是一个几百条的核对任务,靠 AI 建议 # 分批清,而不是「这周必须处理完」的事。把它算进 actionable,每周的 # 摘要都会顶着一个三位数,于是前三项(其中一项是已发生的权限暴露) # 就被淹掉了。 actionable = ( counts[KIND_LEAVER] + counts[KIND_SEAT_LOW] + counts[KIND_AWAITING_MANUAL] ) return { "counts": counts, "severity": SEVERITY_BY_KIND, "actionable_total": actionable, "leaver_still_member": leavers, "seat_low": seats, "awaiting_manual": manual, "unlinked": unlinked, "external_confirmed_total": unlinked["external_confirmed"], # 生效阈值一起带出去 —— 一个说「坐席告急」的清单必须说得出它是 # 按几算的,否则「为什么余 3 不报了」只能靠翻代码回答。 "seat_warn_threshold": cap, "generated_at": _iso(_now()), }