"""GitHub handle 的标签体系 —— 挂在 handle 上,不挂在成员资格上。 ## 为什么要标签,而不是继续用 external_note 585 给了「已确认外部」两个值(``bot`` / ``outside_human``)和一个 255 字的 自由文本备注。两者都撑不住「以后要维护外部人员」这件事: * 枚举是**互斥**的 —— 一个人不能同时是「ByteDance 的员工」和 「TiDB 的社区 maintainer」,而现实里 `crazycs520` 这类 handle 就是两者 都是; * 自由文本**必然腐烂** —— 三个人标同一家公司会写成 ``ByteDance`` / ``字节`` / ``bytedance 的人``,然后「把 ByteDance 的人筛出来做年度复核」 这个动作永远做不成。词表化是唯一能让筛选长期有效的做法。 ## 为什么挂在 handle 上,而不是挂在成员资格上 这是 585 做错的地方,趁现在没有数据改掉。实测: 认不出的 handle: 217 个,但台账 331 行 sre-bot / ti-chi-bot / crazycs520 / Mini256 / lance6716 各在 5 个组织里 **「这个 handle 是什么」是 handle 的属性,不是 (组织, handle) 的属性。** ``crazycs520`` 如果是 ByteDance 的人,他在 5 个组织里都是 ByteDance 的人。 把标签挂在成员资格上意味着标一个 ``sre-bot`` 要写 5 行,而 5 行会漂移 —— 有人只改了其中 2 行,剩下 3 行还是空的,而界面上看不出来。 585 的 ``external_kind`` 保留,但从今往后由标签**派生**:给 handle 打上 ``account:bot`` 时,service 会把它所有活着的成员资格一起确认掉。一个动作, 跨组织一致,不会漂移。 ## 词表内容为什么要播种 空词表没人会用 —— 人看到一个空下拉框就转头去写备注了,而那正是要避免的 东西。所以播种几个从生产画像里能确认的(``@pingcap`` / ``ByteDance`` / ``CNCF``),剩下的让管理员边用边加。 Revision ID: 586 Revises: 585 """ import json import sqlalchemy as sa from alembic import op from app.core.migration_helpers import id_type_for revision = "586" down_revision = "585" branch_labels = None depends_on = None LABELS = "github_handle_labels" LINKS = "github_handle_label_links" #: 分类。刻意只四类,而且每一类回答一个**不同的问题** —— #: 分类多了人会犹豫该放哪一类,然后就不放了。 #: #: * ``account`` 这是什么账号(人 / 机器)。派生 585 的 external_kind。 #: * ``employer`` 他给谁干活。年度复核按这个筛。 #: * ``community`` 他在开源侧是什么身份。决定「离职后该不该保留」。 #: * ``purpose`` 这个机器账号干什么用的。决定「还需不需要它」。 SEED = [ # key, category, display_zh, display_en, color, description ("account:bot", "account", "机器账号", "Machine account", "default", "不是人,是自动化用的账号。要问的是「这个自动化还在用吗、token 谁管」。"), ("account:outside_human", "account", "外部人员", "External person", "default", "别家公司/基金会/高校的人。要问的是「他还需要这个访问权吗」。"), ("employer:pingcap", "employer", "PingCAP", "PingCAP", "blue", "GitHub 资料自称 @pingcap,但 Helix 里认不出是谁 —— 可能是前员工,或还没登记。"), ("employer:bytedance", "employer", "ByteDance", "ByteDance", "cyan", None), ("employer:other", "employer", "其他公司", "Other company", "cyan", "已确认是别家公司的人,但具体哪家还没登记。"), ("community:maintainer", "community", "社区 maintainer", "Community maintainer", "green", "开源项目的正当维护者。这个标签是「离职后是否保留」的依据。"), ("community:contributor", "community", "社区贡献者", "Community contributor", "green", None), ("community:foundation", "community", "基金会/高校", "Foundation / academia", "green", "CNCF、Linux Foundation、高校等。"), ("purpose:ci", "purpose", "CI / 构建", "CI / build", "orange", None), ("purpose:integration", "purpose", "第三方集成", "Third-party integration", "orange", "Zendesk、监控之类挂进来的账号。"), ("purpose:release", "purpose", "发布 / 机器人操作", "Release / bot ops", "orange", None), ] def _has_table(bind, name: str) -> bool: return name in sa.inspect(bind).get_table_names() def upgrade() -> None: bind = op.get_bind() if not _has_table(bind, LABELS): op.create_table( LABELS, # key 是自然键(``employer:bytedance``),不用代理 id:标签在 # 界面、策略、审计详情里都是按 key 引用的,再加一层 uuid 只会 # 让每处都要多一次翻译。 sa.Column("key", sa.String(64), primary_key=True), sa.Column("category", sa.String(24), nullable=False), sa.Column("display_zh", sa.String(64), nullable=False), sa.Column("display_en", sa.String(64), nullable=False), sa.Column("color", sa.String(16), nullable=False, server_default="default"), sa.Column("description", sa.Text()), # 停用而不是删除:一个标签被用过之后删掉,历史上那些链接就变成 # 悬空引用,而「这个人当年被标成什么」是复核要回看的东西。 sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("1")), sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), sa.Column("created_by", id_type_for("identity_persons")), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), ) op.create_index( f"ix_{LABELS}_category", LABELS, ["category", "sort_order"], ) if not _has_table(bind, LINKS): op.create_table( LINKS, # 主键是 (handle, label) —— 同一个标签在同一个 handle 上只能有 # 一条,重复打标签是幂等的而不是攒出两行。 sa.Column("github_login_lower", sa.String(64), primary_key=True), sa.Column("label_key", sa.String(64), primary_key=True), # 原样保留人当时看到的大小写,用于显示;比对一律用 lower。 sa.Column("github_login", sa.String(64), nullable=False), sa.Column("note", sa.String(255)), # 刻意**不**给 label_key 建 FK 到 github_handle_labels: # 词表用「停用」而不是删除,而一个 FK 会把「停用」和「还能不能 # 保留历史」绑在一起。孤立引用由服务层与界面处理(显示为 # 「已停用的标签」而不是消失)。 sa.Column("created_by", id_type_for("identity_persons")), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), ) op.create_index(f"ix_{LINKS}_label", LINKS, ["label_key"]) # 词表播种。空词表没人会用 —— 见模块文档。 if _has_table(bind, LABELS): existing = { r[0] for r in bind.execute(sa.text(f"SELECT `key` FROM {LABELS}")) } for i, (key, cat, zh, en, color, desc) in enumerate(SEED): if key in existing: continue bind.execute( sa.text( f"INSERT INTO {LABELS} " "(`key`, category, display_zh, display_en, color, " " description, is_active, sort_order, created_at, updated_at) " "VALUES (:k, :c, :zh, :en, :col, :d, 1, :o, NOW(), NOW())" ), {"k": key, "c": cat, "zh": zh, "en": en, "col": color, "d": desc, "o": i}, ) # 标签建议策略。bot 那一条(585 的 bot_handle_patterns)保留不动, # 这里是**可建议任意标签**的通用规则表,存成一个设置项。 if _has_table(bind, "platform_settings"): rules = [ # 每条:按什么匹配 / 匹配什么 / 建议哪个标签 {"match": "handle_glob", "value": "*-bot", "label": "account:bot"}, {"match": "handle_glob", "value": "*bot", "label": "account:bot"}, {"match": "handle_glob", "value": "*robot*", "label": "account:bot"}, {"match": "handle_glob", "value": "*-srebot", "label": "account:bot"}, {"match": "profile_company_contains", "value": "pingcap", "label": "employer:pingcap"}, {"match": "profile_company_contains", "value": "bytedance", "label": "employer:bytedance"}, {"match": "profile_company_contains", "value": "cncf", "label": "community:foundation"}, {"match": "profile_company_contains", "value": "linux foundation", "label": "community:foundation"}, {"match": "profile_company_contains", "value": "university", "label": "community:foundation"}, ] bind.execute( sa.text( "INSERT INTO platform_settings (`key`, value, updated_at) " "SELECT :k, :v, NOW() FROM (SELECT 1) t " "WHERE NOT EXISTS (" " SELECT 1 FROM platform_settings WHERE `key` = :k)" ), {"k": "github_org.label_rules", "v": json.dumps(rules)}, ) def downgrade() -> None: bind = op.get_bind() if _has_table(bind, "platform_settings"): bind.execute( sa.text("DELETE FROM platform_settings WHERE `key` = :k"), {"k": "github_org.label_rules"}, ) for t in (LINKS, LABELS): if _has_table(bind, t): op.drop_table(t)