"""GitHub 组织域的输出成形。 单独一个模块,理由与其它域一致:同一个对象在列表页、详情页、MCP 工具 和导出里必须长得一样,否则前端会为每个入口写一份略有差异的类型。 """ from __future__ import annotations from typing import Any from . import constants as const from .models import ( GithubOrg, GithubOrgAutoJoinRule, GithubOrgJoinRequest, GithubOrgMembership, GithubOrgMembershipEvent, ) def _iso(value: Any) -> str | None: return value.isoformat() if value is not None else None def serialize_org( org: GithubOrg, *, member_counts: dict[str, int] | None = None, ) -> dict[str, Any]: """一个组织的公开形状。 ``member_counts`` 由调用方一次性批量算好传进来 —— 每个组织单独发一条 COUNT 查询,是列表页最容易长出来的 N+1。 """ counts = (member_counts or {}) return { "id": org.id, "slug": org.slug, "display_name": org.display_name, "html_url": f"https://github.com/{org.slug}", "description": org.description, "is_community": org.is_community, "offboarding_policy": org.offboarding_policy, "request_enabled": org.request_enabled, "auto_join_enabled": org.auto_join_enabled, "default_role": org.default_role, # 飞书选项值本身不是秘密,但它只对「这个组织能不能被 Helix 发起 # 审批」这一个判断有意义。前端要的是那个判断,不是那串标识 —— # 所以两个都给:布尔值给界面用,原值给管理员排错用。 "feishu_option_value": org.feishu_option_value, "feishu_ready": bool(org.feishu_option_value), "integration_id": org.integration_id, "sort_order": org.sort_order, "is_active": org.is_active, "notes": org.notes, "last_reconciled_at": _iso(org.last_reconciled_at), "last_reconcile_error": org.last_reconcile_error, # 坐席。``seat_remaining`` 会在计划没有真实上限时返回 None —— # free 计划报 seats=92 / filled=203(按字面算余量 -111), # enterprise 报的是整个 enterprise 的池子。两种都不是我们的上限, # 所以宁可什么都不显示,也不显示一个假数字。 "seat_plan": org.seat_plan, "seat_filled": org.seat_filled, "seat_total": org.seat_total, "seat_remaining": const.seat_remaining( org.seat_plan, org.seat_filled, org.seat_total, ), "seat_checked_at": _iso(org.seat_checked_at), "seat_billing_url": const.seat_billing_url(org.slug), "member_count": counts.get(f"{org.id}:active", 0), "pending_invite_count": counts.get(f"{org.id}:pending_invite", 0), "unlinked_count": counts.get(f"{org.id}:unlinked", 0), "created_at": _iso(org.created_at), "updated_at": _iso(org.updated_at), } def serialize_membership( m: GithubOrgMembership, *, org: GithubOrg | None = None, person_label: str | None = None, person_email: str | None = None, person_status: str | None = None, ) -> dict[str, Any]: return { "id": m.id, "org_id": m.org_id, "org_slug": org.slug if org else None, "org_display_name": org.display_name if org else None, "person_id": m.person_id, "person_label": person_label, "person_email": person_email, "person_employment_status": person_status, "github_login": m.github_login, "github_url": f"https://github.com/{m.github_login}", "state": m.state, "role": m.role, "source": m.source, "join_request_id": m.join_request_id, "auto_join_rule_id": m.auto_join_rule_id, "external_invitation_id": m.external_invitation_id, "invited_at": _iso(m.invited_at), "invitation_expires_at": _iso(m.invitation_expires_at), "joined_at": _iso(m.joined_at), "removed_at": _iso(m.removed_at), "removed_reason": m.removed_reason, "removal_source": m.removal_source, "last_verified_at": _iso(m.last_verified_at), # 「人确认过这不是我们的员工」。界面靠它把「还没认」和「认过了, # 是外部的」分成两堆 —— 后者不该继续出现在待办里。 "external_confirmed_at": _iso(m.external_confirmed_at), "external_confirmed_by": m.external_confirmed_by, "external_note": m.external_note, "external_kind": m.external_kind, # GitHub 公开画像。人在界面上判断「这是谁」靠的就是它 —— 而且必须 # 和 AI 当时看的是同一份,否则人没法复核 AI 的判断。 "github_profile": ( m.github_profile if isinstance(m.github_profile, dict) else None ), "profile_fetched_at": _iso(m.profile_fetched_at), "created_at": _iso(m.created_at), "updated_at": _iso(m.updated_at), } def serialize_membership_event( e: GithubOrgMembershipEvent, *, actor_label: str | None = None, ) -> dict[str, Any]: return { "id": e.id, "membership_id": e.membership_id, "event_type": e.event_type, "from_state": e.from_state, "to_state": e.to_state, "actor_person_id": e.actor_person_id, "actor_label": actor_label, "actor_kind": e.actor_kind, "detail": e.detail, "trace_id": e.trace_id, "created_at": _iso(e.created_at), } def serialize_join_request( r: GithubOrgJoinRequest, *, org: GithubOrg | None = None, person_label: str | None = None, ) -> dict[str, Any]: return { "id": r.id, "person_id": r.person_id, "person_label": person_label, "org_id": r.org_id, "org_slug": org.slug if org else None, "org_display_name": org.display_name if org else None, "github_login": r.github_login, "is_community_member": r.is_community_member, "community_team": r.community_team, "reason": r.reason, "status": r.status, "feishu_instance_code": r.feishu_instance_code, "approval_request_id": r.approval_request_id, "decided_at": _iso(r.decided_at), "executed_at": _iso(r.executed_at), "last_error": r.last_error, "created_at": _iso(r.created_at), "updated_at": _iso(r.updated_at), } def serialize_auto_join_rule( rule: GithubOrgAutoJoinRule, *, org: GithubOrg | None = None, match_count: int | None = None, ) -> dict[str, Any]: return { "id": rule.id, "org_id": rule.org_id, "org_slug": org.slug if org else None, "org_display_name": org.display_name if org else None, "match_department_path": rule.match_department_path, "match_job_title": rule.match_job_title, "match_location": rule.match_location, "is_active": rule.is_active, "notes": rule.notes, # 「这条规则现在覆盖多少在职员工」。只在详情/预览接口填,列表页 # 不算 —— 它要遍历人员表,放进列表就是一次 N×M。 "match_count": match_count, "created_at": _iso(rule.created_at), "updated_at": _iso(rule.updated_at), }