"""GitHub 组织域的状态机常量与口径定义。 这个域回答两个问题:**谁应该在哪个 GitHub 组织里**,以及**这件事是 怎么被决定的**。前者是台账(``github_org_memberships``),后者是来源 (``source``)+ 事件流(``github_org_membership_events``)。 命名沿用平台惯例:字符串枚举而不是 DB enum —— 加一个取值只是一次 代码提交,不是一次迁移(参见 provisioning/models.py 的同款注释)。 """ from __future__ import annotations # ── 成员资格状态机 ──────────────────────────────────────────────── # # GitHub 的组织成员资格是**邀请制**的:调用 API 之后对方并没有立刻 # 进组,而是收到一封邀请,要本人点了才算数。这跟平台里其它「授予即 # 生效」的权限(SSO / VPN / 云授权)在形状上就不一样,所以状态机必须 # 把「已邀请」和「已加入」分成两个状态 —— 把它们合并成一个 active, # 等于对外宣称一批其实还没进组的人已经有访问权,离职回收时又会去撤 # 一个不存在的成员资格。 MEMBERSHIP_STATE_PENDING_INVITE = "pending_invite" #: 该给的访问权已经批下来了,但**邀请还没发出去** —— 没配 GitHub 凭证, #: 这一步要人去点。 #: #: 它必须与 ``pending_invite`` 分开,原因很具体:邀请对账靠「GitHub 上 #: 既不是成员、也没有待接受邀请」加上**时间**来判断一条 pending 是过期 #: 还是被拒绝。而人工兜底的记录**从来没有发出过邀请**,所以它在 GitHub #: 上永远是那个形状 —— 合并成一个状态,它会在 7 天内被判成「本人拒绝了」, #: 而 declined 是不可重发的终态。一条本该等人去点的记录,就这样被冻死了。 #: #: 复审第 2 轮抓到的真缺陷(对抗性探针证实过)。 MEMBERSHIP_STATE_AWAITING_MANUAL = "awaiting_manual" MEMBERSHIP_STATE_ACTIVE = "active" MEMBERSHIP_STATE_REMOVED = "removed" #: 邀请被对方明确拒绝,或在 GitHub 侧被取消。终态,但与 removed 分开: #: 「从没进来过」和「进来过又被移除」在审计上不是一回事。 MEMBERSHIP_STATE_DECLINED = "declined" #: GitHub 的组织邀请 7 天过期。过期不等于拒绝 —— 绝大多数是本人没看到, #: 所以这个状态是**可重发**的,而 declined 不是。 MEMBERSHIP_STATE_EXPIRED = "expired" MEMBERSHIP_STATES = ( MEMBERSHIP_STATE_PENDING_INVITE, MEMBERSHIP_STATE_AWAITING_MANUAL, MEMBERSHIP_STATE_ACTIVE, MEMBERSHIP_STATE_REMOVED, MEMBERSHIP_STATE_DECLINED, MEMBERSHIP_STATE_EXPIRED, ) #: 仍然持有(或即将持有)访问权的状态。离职扫描、对账、访问复核都按 #: 这个集合取数 —— 一个 pending_invite 的人随时可能点接受,把他排除在 #: 离职回收之外就是留了一个后门。 MEMBERSHIP_LIVE_STATES = ( MEMBERSHIP_STATE_PENDING_INVITE, # 等人工去点的那条同样算「持有或即将持有访问权」:离职回收必须 # 看得到它(IT 可能已经手工点过邀请了),对账也要把它算进去。 MEMBERSHIP_STATE_AWAITING_MANUAL, MEMBERSHIP_STATE_ACTIVE, ) # ── 成员在组织内的角色 ──────────────────────────────────────────── MEMBERSHIP_ROLE_MEMBER = "member" MEMBERSHIP_ROLE_ADMIN = "admin" MEMBERSHIP_ROLES = (MEMBERSHIP_ROLE_MEMBER, MEMBERSHIP_ROLE_ADMIN) # ── 台账行的来源 ────────────────────────────────────────────────── # # 「这个人为什么在这个组织里」是治理问题的核心。一条无法回答来源的 # 成员记录,在访问复核时只能靠人回忆。 SOURCE_APPROVAL = "approval" # 走了飞书审批 SOURCE_AUTO_JOIN = "auto_join" # 入职时被自动加入规则命中 SOURCE_IMPORT = "import" # 上线时从 GitHub 存量成员导入 SOURCE_MANUAL = "manual" # 管理员在 Helix 里手工登记/添加 SOURCE_RECONCILE = "reconcile" # 对账时在 GitHub 上发现、Helix 没有 MEMBERSHIP_SOURCES = ( SOURCE_APPROVAL, SOURCE_AUTO_JOIN, SOURCE_IMPORT, SOURCE_MANUAL, SOURCE_RECONCILE, ) # ── 移除来源 ────────────────────────────────────────────────────── REMOVAL_SOURCE_OFFBOARDING = "offboarding" REMOVAL_SOURCE_ADMIN = "admin" REMOVAL_SOURCE_SELF = "self" REMOVAL_SOURCE_RECONCILE = "reconcile" # ── 申请单状态机 ────────────────────────────────────────────────── # # 与 VpnApplication / SsoGrant / DomainRegistration 逐字对齐:平台里 # 「提交 → 飞书审批 → 落地」这条路只有一套词汇。 JOIN_REQUEST_STATUS_PENDING = "pending" JOIN_REQUEST_STATUS_APPROVED = "approved" JOIN_REQUEST_STATUS_REJECTED = "rejected" JOIN_REQUEST_STATUS_CANCELLED = "cancelled" #: 审批通过了,但执行失败(GitHub 报错 / 没有配置凭证走了人工兜底)。 #: 与 approved 分开:approved 意味着「已经发出邀请」,一个卡在这里的 #: 单子是需要有人看的,不能被算进成功率。 JOIN_REQUEST_STATUS_FAILED = "failed" JOIN_REQUEST_STATUSES = ( JOIN_REQUEST_STATUS_PENDING, JOIN_REQUEST_STATUS_APPROVED, JOIN_REQUEST_STATUS_REJECTED, JOIN_REQUEST_STATUS_CANCELLED, JOIN_REQUEST_STATUS_FAILED, ) JOIN_REQUEST_OPEN_STATUSES = (JOIN_REQUEST_STATUS_PENDING,) # ── 组织的离职策略 ──────────────────────────────────────────────── # # 公司自有组织(pingcap-inc / PingCAP-QE / tidbcloud)离职一律移除。 # 社区组织(pingcap / tikv)不一样:一个离职的人可能仍然是社区里 # 合法的 maintainer / committer,把他踢出去是对社区治理的越权。所以 # 这是**每个组织自己的一个开关**,而不是一条全局规则 —— 由 GitHub # 管理员(也就是 org owner 本人)决定,Helix 不替社区做主。 OFFBOARDING_POLICY_REMOVE = "remove" OFFBOARDING_POLICY_EXEMPT = "exempt" OFFBOARDING_POLICIES = (OFFBOARDING_POLICY_REMOVE, OFFBOARDING_POLICY_EXEMPT) # ── 事件流的事件类型 ────────────────────────────────────────────── EVENT_INVITED = "invited" EVENT_JOINED = "joined" EVENT_REMOVED = "removed" EVENT_DECLINED = "declined" EVENT_EXPIRED = "expired" EVENT_REINVITED = "reinvited" EVENT_ROLE_CHANGED = "role_changed" EVENT_IMPORTED = "imported" EVENT_RECONCILED = "reconciled" EVENT_LINKED_PERSON = "linked_person" #: 人确认「这个 handle 不是我们的员工」。与 LINKED_PERSON 是一对: #: 两者都是把一条「还没认」推向终态,只是终态不同。 #: 「已确认外部」的两种。分开是因为要问的问题完全不同:别家公司的**人** #: 持有访问权(复核问「他还需要吗」),机器账号问的是「这个自动化还在用吗、 #: token 谁管」。混成一格,「已确认外部 N 条」就回答不了任何问题。 EXTERNAL_KIND_BOT = "bot" EXTERNAL_KIND_OUTSIDE_HUMAN = "outside_human" EXTERNAL_KINDS = frozenset({EXTERNAL_KIND_BOT, EXTERNAL_KIND_OUTSIDE_HUMAN}) EVENT_CONFIRMED_EXTERNAL = "confirmed_external" #: 撤销上面那条 —— 人认错了。必须留痕:一条被误标成外部的成员资格, #: 后果是这个人离职时不会被回收。 EVENT_EXTERNAL_CLEARED = "external_cleared" EVENT_MANUAL_NOTE = "manual_note" #: 落到人工待办上(没配凭证,邀请要人去点)。 EVENT_AWAITING_MANUAL = "awaiting_manual" # ── GitHub 侧的硬事实 ───────────────────────────────────────────── #: GitHub 组织邀请的有效期。写死在这里是因为它是 GitHub 的产品行为, #: 不是我们的可配置项 —— 让它看起来可配置会让人以为改了就能延长。 GITHUB_INVITATION_TTL_DAYS = 7 #: GitHub 用户名允许的字符:字母数字与连字符,不能以连字符开头/结尾, #: 最长 39 字符。用于在提交入口就挡掉明显打错的 handle,而不是等到 #: 调 API 才发现 —— 一个拼错的 handle 走完整条审批链才失败,浪费的是 #: org owner 的时间。 GITHUB_LOGIN_MAX_LEN = 39 GITHUB_LOGIN_PATTERN = r"^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$" #: 组织 slug 的长度上限,与 GitHub 一致。 GITHUB_ORG_SLUG_MAX_LEN = 39 # ── GitHub ID 自助修改(员工提交 → HR Ops 确认 → 回写 HRIS/飞书)── #: 单子状态。与本域其它单子(join request)刻意共用同一套词,但取值集合 #: 更小 —— 这条链路没有飞书审批,所以没有 ``feishu_sent`` 这类中间态。 HANDLE_CHANGE_STATUS_PENDING = "pending" HANDLE_CHANGE_STATUS_APPROVED = "approved" HANDLE_CHANGE_STATUS_REJECTED = "rejected" HANDLE_CHANGE_STATUS_CANCELLED = "cancelled" HANDLE_CHANGE_OPEN_STATUSES = (HANDLE_CHANGE_STATUS_PENDING,) #: 回写 HRIS 的结果。``skipped`` 是"没开关/没工号/连接器没配"这类 #: **本来就不该写**的情形,和 ``failed``(SF 拒了)必须分开:前者不需要 #: 有人去看,后者需要 HR 手工把 SF 改过来。 HANDLE_CHANGE_HRIS_OK = "ok" HANDLE_CHANGE_HRIS_FAILED = "failed" HANDLE_CHANGE_HRIS_SKIPPED = "skipped" #: 申请理由的长度上限。 HANDLE_CHANGE_REASON_MAX_LEN = 500 #: 审批备注的长度上限(驳回理由会原样给到申请人)。 HANDLE_CHANGE_NOTE_MAX_LEN = 500 # ── 坐席 ────────────────────────────────────────────────────────── # # 只有这些计划的坐席数是「这个组织的、我们能动的」上限。与驱动里的 # ``_SEAT_CAPPED_PLANS`` 同一份判断,但域内也要有一份:读台账上的 # ``seat_plan`` 时(界面、周报、MCP)都要判它,而那些地方拿不到驱动。 SEAT_CAPPED_PLANS = frozenset({"team", "business", "business_plus"}) #: 余量低于它就提醒去加坐席。这是**代码默认值**,运维值放在平台设置 #: ``github_org.seat_warn_threshold`` 里(见 governance.seat_threshold)。 #: #: 10 不是拍的:生产上 pingcap-inc 是 260/263(余 3),而一次正常的入职 #: 批次就能吃掉 3 个 —— 阈值必须高于「一批新人」的规模,否则提醒到的时候 #: 已经来不及了(用满之后新邀请直接失败,不是排队)。 #: #: 但这是个**运维口味**的判断,不是不变量:调高会变成噪音,调低会变成 #: 「知道得太晚」,而哪边更难受取决于谁在值班。所以它可以在界面上改, #: 而这个常量只是没人配过时的落点。 SEAT_WARN_THRESHOLD = 10 #: 运维值的平台设置键。生产上 2026-09-01 由管理员设成 1。 SEAT_WARN_THRESHOLD_KEY = "github_org.seat_warn_threshold" def seat_remaining(plan: str | None, filled: int | None, total: int | None) -> int | None: """坐席余量;没有真实上限时返回 ``None``。 ``None`` 与 ``0`` 意思完全不同:前者是「这个计划没有我们能动的上限」, 后者是「用满了」。调用方看到 None 必须跳过,不能当成 0 —— 那会让 free 计划的组织永久报警。 """ if not plan or plan.lower() not in SEAT_CAPPED_PLANS: return None if not isinstance(total, int) or not isinstance(filled, int): return None return total - filled def seat_billing_url(slug: str) -> str: """加坐席的页面 —— 通知里那个「可直接操作的链接」。 指向 ``/settings/billing/seats``,而不是上一层的 ``/settings/billing``。 上一层是账单总览(用量、发票、付款方式),**加坐席的那个按钮不在那里**; 落在总览页等于让人到了地方还要再找一次。这一条是 GitHub 管理员看过 真实页面之后指出来的 —— 我原先按「账单页对所有计划都落得对」选了上一层, 那个理由在「少点一次」面前不成立。 """ return f"https://github.com/organizations/{slug}/settings/billing/seats"