"""GitHub 组织域的管理面 HTTP 接口。 挂在 ``/api/v1/github/orgs``。读走 :func:`can_view_github_org`(GitHub 管理员 + global_admin + 审计/安全 + 管理层),写走 :func:`can_manage_github_org`(只有 GitHub 管理员和 global_admin)。 员工看自己在哪些组织里**不**走这里 —— 那是自助面(PR3),按「是不是 本人」判,不按角色判。 """ from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user, get_real_ip from app.core.exceptions import AppException, ForbiddenError, NotFoundError from app.core.idempotent_route import idempotent from app.core.rbac import can_manage_github_org, can_view_github_org from app.core.response import success_response from app.identity.person_models import IdentityPerson from . import ( auto_join, bot_policy, digest, driver_access, execution, governance, handle_matching, reconcile, report, ) from . import constants as const from . import labels as labels_svc from . import service as svc from .models import GithubHandleLabelLink, GithubOrgMembership from .schemas import ( AutoJoinRuleCreateRequest, AutoJoinRuleUpdateRequest, BotPolicyApplyRequest, BotPolicyPatternsRequest, GithubOrgCreateRequest, GithubOrgUpdateRequest, HandleLabelRequest, LabelRulesRequest, LabelUpsertRequest, MembershipExternalKindRequest, MembershipLinkRequest, MembershipRemoveRequest, ReconcileRequest, ) from .serializers import ( serialize_auto_join_rule, serialize_membership, serialize_membership_event, serialize_org, ) router = APIRouter(prefix="/api/v1/github", tags=["github-org"]) _RESOURCE = "github_org" def _require_view(user: AuthUser) -> None: if not can_view_github_org(user.role_names): raise ForbiddenError( "github_admin, global_admin, auditor, security_officer or " "management role required to read the GitHub organization registry", ) def _require_manage(user: AuthUser) -> None: if not can_manage_github_org(user.role_names): raise ForbiddenError( "github_admin or global_admin role required to manage GitHub " "organizations", ) async def _audit( db: AsyncSession, request: Request, user: AuthUser, *, action: str, resource_id: str | None, detail: dict[str, Any] | None = None, status: str = "success", ) -> None: await AuditService(db).log( trace_id=getattr(request.state, "trace_id", str(uuid.uuid4())), actor_type="user", actor_user_id=user.id, action=action, status=status, resource_type=_RESOURCE, resource_id=resource_id, detail=detail, ip_address=get_real_ip(request), user_agent=request.headers.get("user-agent"), ) # ════════════════════════════════════════════════════════════════════ # 组织登记表 # ════════════════════════════════════════════════════════════════════ @router.get("/orgs") async def list_orgs( request: Request, include_inactive: bool = Query(False), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """所有纳管的 GitHub 组织,带成员计数。 ``meta.can_manage`` 告诉前端该不该渲染写按钮。让只读角色看见一个 点下去必然 403 的按钮,是在用一次失败去传达一条权限规则。 """ _require_view(user) orgs = await svc.list_orgs(db, include_inactive=include_inactive) counts = await svc.org_member_counts(db) # 「这个组织现在能不能自动操作」必须在列表上看得见,而不是等第一次 # 真实邀请失败才发现。解析本身不发任何网络请求 —— 它只回答「凭证配 # 好了吗」,不回答「凭证是对的吗」(后者是集成页的连接测试)。 automation = { o.id: await driver_access.resolve_org_driver(db, o) for o in orgs } return success_response( [ { **serialize_org(o, member_counts=counts), "automation_ready": automation[o.id].automatic, "automation_reason": automation[o.id].reason, "automation_detail": automation[o.id].detail, } for o in orgs ], request=request, meta={ "can_manage": can_manage_github_org(user.role_names), "unlinked_total": await svc.count_unlinked(db), # 这套自动化的全部意义就是这个数应该是 0。 "leavers_still_in_orgs": await svc.count_leavers_still_in_orgs(db), }, ) @router.get("/orgs/{org_id}") async def get_org( org_id: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) org = await svc.get_org(db, org_id) counts = await svc.org_member_counts(db) resolution = await driver_access.resolve_org_driver(db, org) return success_response( { **serialize_org(org, member_counts=counts), "automation_ready": resolution.automatic, "automation_reason": resolution.reason, "automation_detail": resolution.detail, }, request=request, ) @router.post("/orgs", status_code=201) @idempotent("github_org.create") async def create_org( payload: GithubOrgCreateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) org = await svc.create_org( db, slug=payload.slug, display_name=payload.display_name, description=payload.description, is_community=payload.is_community, offboarding_policy=payload.offboarding_policy, request_enabled=payload.request_enabled, auto_join_enabled=payload.auto_join_enabled, default_role=payload.default_role, feishu_option_value=payload.feishu_option_value, integration_id=payload.integration_id, sort_order=payload.sort_order, notes=payload.notes, actor_person_id=str(user.id), ) await _audit( db, request, user, action="github_org.create", resource_id=org.id, detail={ "slug": org.slug, "is_community": org.is_community, "offboarding_policy": org.offboarding_policy, "auto_join_enabled": org.auto_join_enabled, }, ) await db.commit() return success_response(serialize_org(org), request=request) @router.patch("/orgs/{org_id}") async def update_org( org_id: str, payload: GithubOrgUpdateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """按 PATCH 语义更新。审计**按值**记,不是按字段名。 一次把 ``offboarding_policy`` 从 remove 改成 exempt(= 这个组织的 成员离职后留着)和一次改备注,如果审计里都只写 ``fields: [...]``, 事后没人分得出哪次是扩权。 """ _require_manage(user) org = await svc.get_org(db, org_id) changes = payload.model_dump(exclude_unset=True) diff = await svc.update_org(db, org, changes=changes) if not diff: # 没有真实变化就不写审计:一条 from == to 的记录只会稀释审计流。 return success_response(serialize_org(org), request=request) await _audit( db, request, user, action="github_org.update", resource_id=org.id, detail={"slug": org.slug, "changes": diff}, ) await db.commit() return success_response(serialize_org(org), request=request) @router.delete("/orgs/{org_id}") async def retire_org( org_id: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """停用并软删一个组织。存量台账不动 —— 见 service 层的说明。""" _require_manage(user) org = await svc.get_org(db, org_id) await svc.soft_delete_org(db, org) await _audit( db, request, user, action="github_org.retire", resource_id=org.id, detail={"slug": org.slug}, ) await db.commit() return success_response({"id": org.id, "retired": True}, request=request) # ════════════════════════════════════════════════════════════════════ # 成员台账(只读;写入在申请链路 / 离职回收 / 对账里) # ════════════════════════════════════════════════════════════════════ @router.get("/memberships") async def list_memberships( request: Request, org_id: str | None = Query(None), state: str | None = Query(None), person_id: str | None = Query(None), unlinked_only: bool = Query( False, description="只看 GitHub 上有、Helix 认不出是谁的记录", ), label: list[str] | None = Query( None, description=( "按 handle 标签筛。可重复传;多个之间是**或**" "(「筛出 ByteDance 的人」是最常见的用法,而它是单个标签)。" "标签挂在 handle 上,所以一个标签会命中该 handle 在所有组织里的行。" ), ), unlabelled_only: bool = Query( False, description=( "只看**一个标签都没有**的记录。这是「还剩多少要人判断」的那个" "清单 —— 规则命中的已经能批量处理,剩下的才是真正要人看的。" ), ), search: str | None = Query(None, max_length=100), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) from sqlalchemy import func as sa_func q = select(GithubOrgMembership) if org_id: q = q.where(GithubOrgMembership.org_id == org_id) if state: q = q.where(GithubOrgMembership.state == state) if person_id: q = q.where(GithubOrgMembership.person_id == person_id) if unlinked_only: q = q.where( GithubOrgMembership.person_id.is_(None), GithubOrgMembership.state.in_(const.MEMBERSHIP_LIVE_STATES), ) if search: # 只按 handle 搜。按人名搜是另一件事(要 join 人员表,而且对 # ``person_id`` 为空的那批 —— 恰恰是最需要被找到的一批 —— 无效), # 前端用 ``unlinked_only`` + ``person_id`` 两个筛选去覆盖那一侧。 needle = f"%{search.strip().lower()}%" q = q.where( sa_func.lower(GithubOrgMembership.github_login).like(needle) ) # ── 标签筛选 ────────────────────────────────────────────────── # 用 EXISTS 子查询而不是 join:一个 handle 可以有多个标签,join 会让 # 同一条成员资格按标签数重复出现,然后 total 和分页一起错 —— 而那种 # 错法在小数据上看不出来(一个 handle 一个标签时行数刚好对)。 wanted = [x.strip() for x in (label or []) if x.strip()] if wanted: q = q.where( select(GithubHandleLabelLink.label_key) .where( GithubHandleLabelLink.github_login_lower == GithubOrgMembership.github_login_lower, GithubHandleLabelLink.label_key.in_(wanted), ) .exists() ) if unlabelled_only: q = q.where( ~select(GithubHandleLabelLink.label_key) .where( GithubHandleLabelLink.github_login_lower == GithubOrgMembership.github_login_lower, ) .exists() ) total = int( ( await db.execute( select(sa_func.count()).select_from(q.subquery()) ) ).scalar_one() ) rows = list( ( await db.execute( q.order_by(GithubOrgMembership.updated_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) ).scalars().all() ) orgs = {o.id: o for o in await svc.list_orgs(db, include_inactive=True)} person_ids = {m.person_id for m in rows if m.person_id} people: dict[str, IdentityPerson] = {} if person_ids: people = { str(p.id): p for p in ( await db.execute( select(IdentityPerson).where( IdentityPerson.id.in_([uuid.UUID(p) for p in person_ids]) ) ) ).scalars().all() } from app.core.response import paginated_response # 标签一次批量取,不逐行查 —— 一页 20 行,逐行就是 20 次往返。 # 注意标签挂在 handle 上,所以同一个 handle 的多行共享同一批标签。 label_map = await labels_svc.labels_for_handles( db, [m.github_login for m in rows], ) data = [] for m in rows: person = people.get(m.person_id or "") row = serialize_membership( m, org=orgs.get(m.org_id), person_label=(person.display_name if person else None), person_email=(person.work_email if person else None), person_status=(person.employment_status if person else None), ) row["labels"] = label_map.get(m.github_login_lower, []) data.append(row) return paginated_response(data, total, page, page_size, request=request) @router.get("/memberships/{membership_id}/events") async def membership_events( membership_id: str, request: Request, limit: int = Query(100, ge=1, le=500), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """一条成员资格的完整来龙去脉。访问复核时问的就是这个。""" _require_view(user) events = await svc.list_events(db, membership_id, limit=limit) actor_ids = {e.actor_person_id for e in events if e.actor_person_id} # display_name 可空(HRIS 少数记录没有),所以值类型是 str | None —— # 不用空字符串兜底:序列化那边把 None 原样吐出去,前端才知道 # 「这个人没有显示名」而不是「有一个叫空字符串的人」。 labels: dict[str, str | None] = {} if actor_ids: rows = ( await db.execute( select(IdentityPerson).where( IdentityPerson.id.in_([uuid.UUID(a) for a in actor_ids]) ) ) ).scalars().all() labels = {str(p.id): p.display_name for p in rows} return success_response( [ serialize_membership_event( e, actor_label=labels.get(e.actor_person_id or ""), ) for e in events ], request=request, ) # ════════════════════════════════════════════════════════════════════ # 自动加入规则 # ════════════════════════════════════════════════════════════════════ @router.get("/auto-join-rules") async def list_auto_join_rules( request: Request, org_id: str | None = Query(None), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """规则清单。**不算命中数** —— 那要遍历人员表,放进列表就是一次 N×M。 命中数在 ``/auto-join-rules/{id}/preview`` 上按需算。""" _require_view(user) rules = await auto_join.list_rules(db, org_id=org_id) orgs = {o.id: o for o in await svc.list_orgs(db, include_inactive=True)} return success_response( [serialize_auto_join_rule(r, org=orgs.get(r.org_id)) for r in rules], request=request, meta={"can_manage": can_manage_github_org(user.role_names)}, ) @router.get("/auto-join-rules/{rule_id}/preview") async def preview_auto_join_rule( rule_id: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """这条规则现在覆盖哪些在职员工,其中多少人还没登记 GitHub 用户名。 第二个数是这条规则真正的落地率缺口 —— 生产上在职员工只有约 63% 登记了 handle,没有它管理员会以为配好规则就等于这些人都会进组。 """ _require_view(user) rule = await auto_join.get_rule(db, rule_id) return success_response( await auto_join.preview_rule(db, rule), request=request, ) @router.post("/auto-join-rules", status_code=201) @idempotent("github_org.auto_join_rule.create") async def create_auto_join_rule( payload: AutoJoinRuleCreateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) rule = await auto_join.create_rule( db, org_id=payload.org_id, match_department_path=payload.match_department_path, match_job_title=payload.match_job_title, match_location=payload.match_location, is_active=payload.is_active, notes=payload.notes, actor_person_id=str(user.id), ) org = await svc.get_org(db, rule.org_id) await _audit( db, request, user, action="github_org.auto_join_rule.create", resource_id=rule.id, detail={ "org": org.slug, "match_department_path": rule.match_department_path, "match_job_title": rule.match_job_title, "match_location": rule.match_location, "is_active": rule.is_active, }, ) await db.commit() return success_response( serialize_auto_join_rule(rule, org=org), request=request, ) @router.patch("/auto-join-rules/{rule_id}") async def update_auto_join_rule( rule_id: str, payload: AutoJoinRuleUpdateRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) rule = await auto_join.get_rule(db, rule_id) diff = await auto_join.update_rule( db, rule, changes=payload.model_dump(exclude_unset=True), ) org = await svc.get_org(db, rule.org_id) if not diff: return success_response( serialize_auto_join_rule(rule, org=org), request=request, ) await _audit( db, request, user, action="github_org.auto_join_rule.update", resource_id=rule.id, detail={"org": org.slug, "changes": diff}, ) await db.commit() return success_response( serialize_auto_join_rule(rule, org=org), request=request, ) @router.delete("/auto-join-rules/{rule_id}") async def delete_auto_join_rule( rule_id: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """软删。台账上引用它的成员记录不动 —— 规则消失不该把已经发生的事实 一起带走。""" _require_manage(user) rule = await auto_join.get_rule(db, rule_id) org = await svc.get_org(db, rule.org_id) await auto_join.delete_rule(db, rule) await _audit( db, request, user, action="github_org.auto_join_rule.delete", resource_id=rule.id, detail={ "org": org.slug, "match_department_path": rule.match_department_path, }, ) await db.commit() return success_response({"id": rule.id, "deleted": True}, request=request) # ════════════════════════════════════════════════════════════════════ # 对账与存量导入 # ════════════════════════════════════════════════════════════════════ @router.post("/orgs/{org_id}/reconcile") @idempotent("github_org.reconcile", key_params=["org_id"]) async def reconcile_one_org( org_id: str, request: Request, request_body: ReconcileRequest | None = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """手工对一个组织跑一次对账。 与每日 cron 同一份实现。``apply_removals=false`` 是**存量导入**模式: 只往台账里加,不因为「Helix 什么都不知道」去动任何东西 —— 第一次 导入时台账本来就是空的,那个方向的判断毫无意义。 """ _require_manage(user) org = await svc.get_org(db, org_id) apply_removals = ( request_body.apply_removals if request_body is not None else True ) trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) summary = await reconcile.reconcile_org( db, org, trace_id=trace_id, apply_removals=apply_removals, ) await _audit( db, request, user, action="github_org.reconcile.manual", resource_id=org.id, detail={"apply_removals": apply_removals, "summary": summary}, ) await db.commit() return success_response(summary, request=request) @router.post("/import-existing") @idempotent("github_org.import_existing") async def import_existing( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """存量导入:把每个组织上现有的成员一次性拉进台账。 上线后跑一次。**幂等** —— 重复跑只会把新出现的人补进去,不会重复 落行(台账按 ``(org, handle)`` 收敛)。 导入后台账里通常会有一批 ``person_id`` 为空的记录:GitHub 上的 handle 未必对得上人。那个数正是「离职自动回收够不着的部分」。 它**不会自己消失** —— 实测 217 个认不出的 handle 里,归一后与任何 企业邮箱前缀字面相同的是 0 个。清它要走治理面的「待处理」:GitHub 画像 + AI 建议,人一条条确认「是谁」或「确实是外部人员」。 """ _require_manage(user) trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) totals = await reconcile.import_existing_members(db, trace_id=trace_id) await _audit( db, request, user, action="github_org.import_existing", resource_id=None, detail=totals, ) await db.commit() return success_response(totals, request=request) # ════════════════════════════════════════════════════════════════════ # 治理待办 —— 「有问题」到「有人能点」之间那一段 # ════════════════════════════════════════════════════════════════════ async def _membership_or_404( db: AsyncSession, membership_id: str, ) -> tuple[GithubOrgMembership, Any]: row = ( await db.execute( select(GithubOrgMembership, svc.GithubOrg) .join( svc.GithubOrg, svc.GithubOrg.id == GithubOrgMembership.org_id, ) .where(GithubOrgMembership.id == membership_id) ) ).first() if row is None: raise NotFoundError(f"GitHub org membership {membership_id} not found") return row[0], row[1] @router.get("/report") async def org_report( request: Request, days: int = Query(report.TREND_DAYS, ge=7, le=365), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """总览报表:这套自动化做成了多少。 与 ``/action-items`` 是同一批底层语义、相反的取数方向 —— 待办看缺口, 报表看构成与趋势。**最该被看见的是来源分布**:成员总数几乎不动,说明 问题的是这些成员资格里有多少是走了自动化(approval / auto_join), 有多少只是我们上线时或每日对账**发现**的存量(import / reconcile)。 """ _require_view(user) return success_response( await report.build_report(db, days=days), request=request, ) @router.get("/action-items") async def action_items( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """治理待办的全量清单。界面上的「待处理」页读它。 与每周摘要**同一份实现** —— 两份「什么算待办」的判断迟早会分叉,而 那种分叉的表现是界面上有、周报里没有,或者反过来,而且谁都不会发现。 """ _require_view(user) return success_response( await governance.collect_action_items(db), request=request, ) @router.post("/memberships/{membership_id}/remove") @idempotent("github_org.membership_remove", key_params=["membership_id"]) async def remove_membership( membership_id: str, payload: MembershipRemoveRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """管理员手工把一个人移出组织。 这就是治理待办里「离职了还在组织里」那条的处理按钮。走的是和离职回收 **完全相同**的执行路径(``execution.revoke_membership``),所以驱动失败 时同样不会在台账上留一条假的回收记录。 驱动失败向上抛成 502 而不是静默返回 —— 点了「移除」之后看到「成功」而 人其实还在组织里,是这套东西最不能出的错。 """ _require_manage(user) membership, org = await _membership_or_404(db, membership_id) trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) outcome = await execution.revoke_membership( db, membership=membership, org=org, reason=payload.reason, removal_source=const.REMOVAL_SOURCE_ADMIN, trace_id=trace_id, actor_person_id=str(user.id), ) await _audit( db, request, user, action="github_org.membership.remove_manual", resource_id=membership.id, status="success" if outcome.ok else "failure", detail={ "org": org.slug, "github_login": membership.github_login, "reason": payload.reason, "outcome": outcome.outcome, "error": outcome.error, }, ) await db.commit() if not outcome.ok: # 502 而不是 500:失败在 GitHub 那一侧,而管理员需要看到原因才能 # 决定下一步(是重试,还是这个 handle 已经不在了)。 raise AppException( code="GITHUB_ORG_REMOVE_FAILED", message=f"GitHub 拒绝了这次移除:{outcome.error}", status_code=502, ) return success_response( { "outcome": outcome.outcome, "membership": serialize_membership(membership), # manual 表示凭证没配好,人还在组织里 —— 界面必须说清楚, # 否则它长得和成功一模一样。 "requires_manual_action": ( outcome.outcome == execution.OUTCOME_MANUAL ), "detail": outcome.error, }, request=request, ) @router.post("/memberships/{membership_id}/link") async def link_membership( membership_id: str, payload: MembershipLinkRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """把一条「还没认出是谁」的成员资格关联到某个人,或解绑。 这是让离职回收能覆盖到这条记录的唯一办法:回收扫的是 ``person_id``, ``person_id`` 为空的行它根本看不到。 """ _require_manage(user) membership, org = await _membership_or_404(db, membership_id) person_id = (payload.person_id or "").strip() or None if person_id: # 先把字符串解析成 UUID,失败就当「查无此人」。 # 直接把它喂给 ``uuid.UUID()`` 会抛 ValueError —— 一个没人处理的 # ValueError 在这里是 500,而调用方看到 500 会以为是我们坏了、 # 于是重试;实际上这是一次它自己发来的坏输入,重试永远不会好。 try: person_uuid = uuid.UUID(person_id) except ValueError: raise NotFoundError(f"Person {person_id} not found") from None # 关联到一个不存在的人会得到一条永远对不上的记录,而它在界面上 # 长得和正常关联一模一样。先验证。 exists = ( await db.execute( select(IdentityPerson.id).where( IdentityPerson.id == person_uuid, IdentityPerson.deleted_at.is_(None), ) ) ).scalar_one_or_none() if exists is None: raise NotFoundError(f"Person {person_id} not found") trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) await svc.relink_membership( db, membership, person_id=person_id, actor_person_id=str(user.id), trace_id=trace_id, ) await _audit( db, request, user, action="github_org.membership.link", resource_id=membership.id, detail={ "org": org.slug, "github_login": membership.github_login, "person_id": person_id, }, ) await db.commit() return success_response(serialize_membership(membership), request=request) @router.post("/memberships/{membership_id}/confirm-external") async def confirm_membership_external( membership_id: str, payload: MembershipExternalKindRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """确认「这个 handle 不是我们的员工」,让它离开待办队列。 不动 GitHub,只动台账 —— 外部协作者留在开源组织里是正常的。这个动作 存在的理由是让「还没认出是谁」这个数**能归零**:上线实测 331 条里 相当一部分是别家公司、基金会、高校的人,永远也认不出来,而一个永远 不归零的待办数等于没有待办数。 """ _require_manage(user) membership, org = await _membership_or_404(db, membership_id) trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) await svc.confirm_external( db, membership, actor_person_id=str(user.id), note=payload.note, kind=payload.kind, trace_id=trace_id, ) await _audit( db, request, user, action="github_org.membership.confirm_external", resource_id=membership.id, detail={ "org": org.slug, "github_login": membership.github_login, "note": payload.note, "kind": payload.kind, }, ) await db.commit() return success_response(serialize_membership(membership), request=request) @router.delete("/memberships/{membership_id}/confirm-external") async def clear_membership_external( membership_id: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """撤销上面那个确认 —— 人认错了,把它放回待办队列。 必须能撤销:一条被误标成外部的成员资格,后果是这个人离职时不会被回收。 """ _require_manage(user) membership, org = await _membership_or_404(db, membership_id) trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) await svc.clear_external( db, membership, actor_person_id=str(user.id), trace_id=trace_id, ) await _audit( db, request, user, action="github_org.membership.clear_external", resource_id=membership.id, detail={"org": org.slug, "github_login": membership.github_login}, ) await db.commit() return success_response(serialize_membership(membership), request=request) @router.get("/labels") async def list_labels( request: Request, include_inactive: bool = Query(False), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """标签词表。 **为什么是词表而不是自由文本**:585 那个 255 字的 ``external_note`` 必然腐烂 —— 三个人标同一家公司会写成 ``ByteDance`` / ``字节`` / ``bytedance 的人``,于是「把 ByteDance 的人筛出来做年度复核」永远做 不成。 """ _require_view(user) rows = await labels_svc.list_labels(db, include_inactive=include_inactive) return success_response( [ { "key": r.key, "category": r.category, "display_zh": r.display_zh, "display_en": r.display_en, "color": r.color, "description": r.description, "is_active": r.is_active, "sort_order": r.sort_order, } for r in rows ], request=request, ) @router.put("/labels") async def upsert_label( payload: LabelUpsertRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """新建或改一个标签。""" _require_manage(user) row = await labels_svc.upsert_label( db, key=payload.key, category=payload.category, display_zh=payload.display_zh, display_en=payload.display_en, color=payload.color, description=payload.description, sort_order=payload.sort_order, is_active=payload.is_active, actor_person_id=str(user.id), ) await _audit( db, request, user, action="github_org.label.upsert", resource_id=row.key, detail={"category": row.category}, ) await db.commit() return success_response({"key": row.key}, request=request) @router.delete("/labels/{label_key}") async def retire_label( label_key: str, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """停用一个标签 —— **不是删除**。 一个被用过的标签删掉之后,历史上那些链接就成了悬空引用,而「这个人 当年被标成什么」正是访问复核要回看的东西。 """ _require_manage(user) if not await labels_svc.deactivate_label(db, label_key): raise NotFoundError(f"Label {label_key} not found") await _audit( db, request, user, action="github_org.label.retire", resource_id=label_key, detail=None, ) await db.commit() return success_response({"key": label_key, "is_active": False}, request=request) @router.post("/handle-labels") async def attach_handle_labels( payload: HandleLabelRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """给一个 handle 打标签。**一次生效跨它所在的所有组织。** 这是 585 做错、这里改掉的地方:实测 ``sre-bot`` / ``ti-chi-bot`` / ``crazycs520`` 各在 5 个组织里,而把分类挂在成员资格上意味着标一次要 写 5 行、5 行会漂移。 打上 ``account:bot`` / ``account:outside_human`` 时,该 handle 所有活着 的成员资格会一起被确认为外部(kind 取自标签)—— 治理队列的语义不变, 只是不再需要人对 5 行分别操作。 """ _require_manage(user) trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) try: out = await labels_svc.attach( db, github_login=payload.github_login, label_keys=payload.label_keys, note=payload.note, actor_person_id=str(user.id), trace_id=trace_id, ) except ValueError as e: raise AppException( code="GITHUB_ORG_LABEL_INVALID", message=str(e), status_code=422, ) from None await _audit( db, request, user, action="github_org.handle_label.attach", resource_id=payload.github_login, detail=out, ) await db.commit() return success_response(out, request=request) @router.delete("/handle-labels") async def detach_handle_labels( payload: HandleLabelRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """摘掉标签。摘掉 ``account:*`` 时同步清掉外部确认。""" _require_manage(user) trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) out = await labels_svc.detach( db, github_login=payload.github_login, label_keys=payload.label_keys, actor_person_id=str(user.id), trace_id=trace_id, ) await _audit( db, request, user, action="github_org.handle_label.detach", resource_id=payload.github_login, detail=out, ) await db.commit() return success_response(out, request=request) @router.get("/label-suggestions") async def label_suggestions( request: Request, limit: int = Query(200, ge=1, le=500), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """按规则给「还没打任何标签」的认不出 handle 建议标签。 **只建议。** 规则是启发式:``robot-liu`` 会命中 ``*robot*``,而 company 写着 "PingCAP" 的 handle 可能是前员工而不是在职员工。把一个员工标成 机器账号,后果是他离职时不被回收。 """ _require_manage(user) return success_response( await labels_svc.suggest(db, limit=limit), request=request, ) @router.get("/label-rules/impact") async def label_rule_impact( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """每条规则现在会打给多少个 handle。 「启用」如果没有这个数字就是一个盲开关:点下去之后才知道它影响的是 3 个人还是 180 个人,而那时标签已经落库了。 """ _require_manage(user) return success_response( await labels_svc.rule_impact(db), request=request, ) @router.post("/label-rules/ai-propose") async def ai_propose_label_rules( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """让模型基于真实的 company 取值分布提议规则。 **提议出来的规则一律不启用。** 一条过宽的规则(`value` 写成 `co`)会 一次给几十个人打上错标签,而错标签在离职回收那一侧是有代价的 —— 所以启用是管理员点的,而开关旁边显示它会命中多少个 handle。 """ _require_manage(user) out = await labels_svc.propose_rules_with_ai(db) if out["added"]: await _audit( db, request, user, action="github_org.label_rules.ai_propose", resource_id=None, detail={"added": len(out["added"]), "enabled": 0}, ) await db.commit() return success_response(out, request=request) @router.post("/label-rules/apply") async def apply_label_rules( request: Request, dry_run: bool = Query(False, description="只算不写,用来在点之前看一眼"), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """把已启用的规则现在就落一次。只加不减。 每天的成员对账尾部也会跑一次同样的事 —— 这个端点是给「刚启用了一条 规则,不想等到明天」用的。 """ _require_manage(user) out = await labels_svc.apply_enabled_rules( db, dry_run=dry_run, actor_person_id=str(user.id), trace_id=getattr(request.state, "trace_id", None), ) if not dry_run and out["labelled"]: await _audit( db, request, user, action="github_org.label_rules.apply", resource_id=None, detail=out, ) await db.commit() return success_response(out, request=request) @router.put("/label-rules") async def put_label_rules( payload: LabelRulesRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """改建议规则。指向不存在的标签会被拒绝 —— 一条永远建议不出东西的 规则,看起来和「没有匹配」一模一样。""" _require_manage(user) try: rules = await labels_svc.set_rules(db, payload.rules) except ValueError as e: raise AppException( code="GITHUB_ORG_LABEL_RULE_INVALID", message=str(e), status_code=422, ) from None await _audit( db, request, user, action="github_org.label_rules.update", resource_id=None, detail={"count": len(rules)}, ) await db.commit() return success_response({"rules": rules}, request=request) @router.get("/bot-policy") async def get_bot_policy( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """当前的机器账号命名策略,以及它现在会命中哪些 handle(只读预演)。 为什么是策略而不是自动判定:**GitHub 自己的 ``type`` 字段在这里用不了。** 生产上 7 个 bot 账号(``sre-bot`` / ``ti-chi-bot`` / ``tidb-ci-bot`` …) 逐个查 ``GET /users/{login}`` **全部报 ``type=User``** —— GitHub 的 ``Bot`` 类型只给 GitHub App 身份,而这些是人建的普通账号拿来当机器人用。 所以只能靠命名约定,而命名约定会误伤(``robot-liu`` 命中 ``*robot*``)。 """ _require_view(user) return success_response(await bot_policy.preview(db), request=request) @router.put("/bot-policy") async def put_bot_policy( payload: BotPolicyPatternsRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """改策略。新的 bot 命名惯例会出现,而那不该等一次发布。""" _require_manage(user) try: pats = await bot_policy.set_patterns( db, payload.patterns, actor_person_id=str(user.id), ) except ValueError as e: raise AppException( code="GITHUB_ORG_BOT_POLICY_INVALID", message=str(e), status_code=422, ) from None await _audit( db, request, user, action="github_org.bot_policy.update", resource_id=None, detail={"patterns": pats}, ) await db.commit() return success_response(await bot_policy.preview(db), request=request) @router.post("/bot-policy/apply") @idempotent("github_org.bot_policy_apply") async def apply_bot_policy( request: Request, payload: BotPolicyApplyRequest | None = None, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """把策略命中的 handle 批量标记为机器账号。 **显式调用,没有定时自动版本。** 判定来自命名约定,而命名约定会误伤: ``robot-liu`` 这样的真名会命中 ``*robot*``,把一个员工标成机器账号的 后果是他离职时不被回收 —— 和挂错人一样,是一次静默的权限泄漏。所以 这一步永远由人按下,而且可以只勾选其中几个(``handles``)。 """ _require_manage(user) body = payload or BotPolicyApplyRequest() trace_id = getattr(request.state, "trace_id", str(uuid.uuid4())) pv = await bot_policy.preview(db) wanted = ( {h.strip().lower() for h in body.handles if h.strip()} if body.handles else None ) marked, skipped = [], [] for item in pv["items"]: if wanted is not None and item["handle"].lower() not in wanted: continue for mid in item["membership_ids"]: membership, _org = await _membership_or_404(db, mid) try: await svc.confirm_external( db, membership, actor_person_id=str(user.id), note=(body.note or item["reason"])[:255], kind=const.EXTERNAL_KIND_BOT, trace_id=trace_id, ) except Exception as e: # noqa: BLE001 # 一个失败不该让整批回滚 —— 其余的标记本身是有效的。 skipped.append({"membership_id": mid, "error": str(e)[:200]}) continue marked.append(item["handle"]) await _audit( db, request, user, action="github_org.bot_policy.apply", resource_id=None, detail={"handles": marked, "skipped": skipped, "patterns": pv["patterns"]}, ) await db.commit() return success_response( { "marked_handles": marked, "marked_memberships": sum( len(i["membership_ids"]) for i in pv["items"] if i["handle"] in marked ), "skipped": skipped, "patterns": pv["patterns"], }, request=request, ) @router.get("/link-suggestions") async def link_suggestions( request: Request, org_id: str | None = Query(None), limit: int = Query(30, ge=1, le=100), use_ai: bool = Query(True), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """给「还没认出是谁」的 handle 产出建议:是谁,或确实是外部人员。 先拉 GitHub 公开个人资料(姓名 / company / 邮箱)再打分,然后可选地让 模型在**给定候选集内**复核。之所以必须拉画像:handle 字符串本身几乎 没有信号 —— 实测 217 个认不出的 handle 里,归一后与任何企业邮箱前缀 字面相同的是 0 个。 **只产出建议,永不自动写台账。** 落库要人点上面那两个端点之一。 """ _require_manage(user) # 画像要走 GitHub 只读接口,凭证从组织的集成上解析。解析不到就只用 # 库里的缓存 —— 一个集成故障不该让整个治理页面报错。 fetcher = None orgs = await svc.list_orgs(db, include_inactive=False) for org in orgs: resolution = await driver_access.resolve_org_driver(db, org) if resolution.automatic and resolution.driver is not None: driver = resolution.driver async def fetcher(login: str, _d: Any = driver) -> Any: return await _d.get_user_profile(login) break result = await handle_matching.suggest_links( db, org_id=org_id, limit=limit, use_ai=use_ai, fetch_profile=fetcher, ) # 画像会被写回台账(缓存),所以这里要提交 —— 一个 GET 端点写库看起来 # 别扭,但另一个选择是每次翻页都重打几十次 GitHub。 await db.commit() return success_response(result, request=request) @router.post("/governance-digest/preview") async def preview_governance_digest( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """看一眼每周摘要现在会发给谁、长什么样 —— **不发**。 「有 dispatch 代码」不等于「有人收到过」。这个端点回答的正是那个问题: 收件人清单是空的时候,那份摘要每周都在静默消失。 """ _require_manage(user) payload = await governance.collect_action_items(db) card = digest.build_digest(payload) recipients = await digest.digest_recipients(db) return success_response( { "would_send": card is not None and bool(recipients), "skipped": ( None if card is not None and recipients else ("no_action_items" if card is None else "no_recipients") ), "recipients": [ { "person_id": str(p.id), "label": p.display_name or p.work_email, "email": p.work_email, } for p in recipients ], "title": card["title"] if card else None, "body_text": card["body_text"] if card else None, "counts": payload["counts"], "actionable_total": payload["actionable_total"], }, request=request, )