diff --git a/AGENTS.md b/AGENTS.md index 274f475..738927c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,3 +51,13 @@ Reviewers should check: - whether the PR introduces hidden scope expansion - whether research claims, data interpretation, or release decisions still need human judgment +## Contributor Identity Self-Serve + +Any agent can add, remove, or verify co-author contributor identities with the self-serve script: + +- List identities: `python scripts/build_contributors.py --list` +- Add identities (branch + commit with trailers): `python scripts/build_contributors.py --apply --tools codex,claude,renovate` +- Remove identities (from catalog + script): `python scripts/build_contributors.py --remove --tools renovate` +- Verify presence: `python scripts/build_contributors.py --check --tools codex` + +Read `docs/add-contributors-with-agent.md` and `docs/contributor-catalog.md` before changing identities. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7632ea9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# CLAUDE.md + +本仓库支持 Agent 协作(Issue → 分支 → PR → CI → Review → 合并)。 + +## 核心规则 + +- 不要直接推 `main`:建分支、做改动、开 PR、等 Review。 +- 只改 Issue 或维护者明确允许的文件;不要覆盖已有配置。 +- 不提交密钥、Token、个人邮箱、本地绝对路径、私有数据。 +- 不伪造其他 Agent 身份;使用 GitHub noreply 或已识别的 bot 身份。 + +## 贡献者身份自助(共同作者 → Contributors) + +给本仓库添加 / 移除 / 验证共同作者贡献者身份时,使用自助脚本: + +```bash +# 查看可选身份(A 组:codex/claude;C 组:33 个真实 Bot 账号) +python scripts/build_contributors.py --list + +# 添加(自动建分支 + 提交 + 尾注) +python scripts/build_contributors.py --apply --tools codex,claude,renovate + +# 移除(从目录与脚本删除,停止再新增) +python scripts/build_contributors.py --remove --tools renovate + +# 验证最近 100 提交是否出现该身份 +python scripts/build_contributors.py --check --tools codex +``` + +操作说明见 `docs/add-contributors-with-agent.md` 与 `docs/contributor-catalog.md`。 diff --git a/docs/add-contributors-with-agent.md b/docs/add-contributors-with-agent.md new file mode 100644 index 0000000..607f07c --- /dev/null +++ b/docs/add-contributors-with-agent.md @@ -0,0 +1,71 @@ +# 给 Agent 的操作手册:添加 / 移除 / 验证共同作者贡献者 + +本文写给**任意编程 Agent**(Codex、Claude Code、Gemini CLI、Cursor 等)阅读。 +目标:在**任意 GitHub 仓库**里,用最少配置把「共同作者」身份变成 Contributors 页面的贡献者, +或反过来移除某个身份。 + +前置条件(仓库内已有本套件): + +```text +scripts/build_contributors.py # 自助脚本(必须) +docs/contributor-catalog.md # 身份目录(必须) +``` + +## 快速开始(三步) + +### 1. 查看可选身份 + +```bash +python scripts/build_contributors.py --list +``` + +输出两组: +- **A 组**:codex、claude(GitHub 官方注册 AI 身份,尾注即计入页面) +- **C 组**:33 个真实 GitHub Bot 账号(如 renovate[bot]、mergify[bot] 等,必显示) + +### 2. 添加身份(自动建分支 + 提交 + 尾注) + +```bash +# 选择部分身份 +python scripts/build_contributors.py --apply --tools codex,claude,renovate + +# 或全部真实 Bot 账号 +python scripts/build_contributors.py --apply --bots + +# 自定义提交标题 +python scripts/build_contributors.py --apply --tools codex,renovate --subject "chore: attribute identities" +``` + +脚本会:新建分支 `contributor/add-` → 追加 `activity/contributor-attributions.csv` 记录 → 用带 +`Co-Authored-By:` 尾注的提交信息提交。随后按脚本提示推送并开 PR: + +```bash +git push -u origin <分支名> +``` + +PR 合并(squash 即可,尾注会保留)后,刷新仓库 Contributors 页面核对。 + +### 3. 移除身份(停止再新增) + +```bash +python scripts/build_contributors.py --remove --tools renovate +``` + +会同时从 `build_contributors.py` 和 `contributor-catalog.md` 里删除该身份,之后不再被选中。 +**还要**把仓库变量 `AGENT_CHECKIN_AUTHORS` 里对应的身份删掉(如果 check-in 在用)。 +注意:`--remove` 只影响未来;**历史提交仍会计入 Contributors 页面**,想从页面抹去需重写历史(高风险,不推荐)。 + +### 验证 + +```bash +# 检查最近 100 个提交里是否出现某身份(PRESENT / ABSENT) +python scripts/build_contributors.py --check --tools codex,renovate +``` + +## 通用注意事项(所有 Agent 都适用) + +- 合并请用 squash(或保留提交信息的 merge);squash 会保留尾注(已实测 6/24/3 条都完整)。 +- Contributors 页面聚合有延迟(几分钟到 1 小时),刚合并看不到是正常的。 +- 同一提交里两个身份共用邮箱时,GitHub squash 会按邮箱去重(如 Codex 与 ChatGPT 同为 noreply@openai.com,只保留一个)。 +- 不要伪造身份:只使用目录里列出的、GitHub 能识别的真实邮箱。 +- 本仓库还提供 `AGENTS.md` / `CLAUDE.md`,按你的 Agent 类型读对应文件即可;上面的命令在 git + python 环境通用,与具体 Agent 无关。 diff --git a/docs/contributor-catalog.md b/docs/contributor-catalog.md index 831a4d7..3b9c0c6 100644 --- a/docs/contributor-catalog.md +++ b/docs/contributor-catalog.md @@ -75,6 +75,9 @@ python scripts/build_contributors.py --bots # 选择全部(A+C) python scripts/build_contributors.py --all +# 自助:自动建分支 + 提交 + 尾注(推荐给 Agent) +python scripts/build_contributors.py --apply --tools codex,renovate,snyk + # 直接生成带尾注的完整提交信息 python scripts/build_contributors.py --bots --commit-message "feat: example" ``` @@ -82,6 +85,15 @@ python scripts/build_contributors.py --bots --commit-message "feat: example" 把生成的 `Co-Authored-By:` 行追加到 commit message 末尾再提交即可。 提交进入默认分支(直接 push 或 squash 合并均可保留尾注)后,刷新 Contributors 页面验证。 +## 如何移除身份 + +- **停止再新增**:`python scripts/build_contributors.py --remove --tools renovate` + 会同时从本目录和 `build_contributors.py` 删除该身份;并把仓库变量 `AGENT_CHECKIN_AUTHORS` + 里对应的身份删掉(若 check-in 在用)。 +- **历史提交仍计入页面**:`--remove` 只影响未来;想从 Contributors 页面抹去历史只能重写 + 默认分支历史(force-push,高风险,不推荐)。 +- **验证**:`python scripts/build_contributors.py --check --tools renovate` 应显示 ABSENT。 + ## 迁移到其他仓库(三步) 1. 复制本目录的 `docs/contributor-catalog.md` 和 `scripts/build_contributors.py` 到目标仓库。 diff --git a/scripts/build_contributors.py b/scripts/build_contributors.py index b5ff94d..7d4b439 100644 --- a/scripts/build_contributors.py +++ b/scripts/build_contributors.py @@ -5,12 +5,11 @@ - A group: codex, claude (GitHub-registered AI identities) - C group: real GitHub bot accounts (guaranteed to show) -Examples: +Self-serve for agents: python scripts/build_contributors.py --list - python scripts/build_contributors.py --tools codex,renovate - python scripts/build_contributors.py --bots - python scripts/build_contributors.py --all - python scripts/build_contributors.py --bots --commit-message "feat: example" + python scripts/build_contributors.py --apply --tools codex,claude,renovate + python scripts/build_contributors.py --remove --tools renovate + python scripts/build_contributors.py --check --tools codex """ from __future__ import annotations @@ -18,6 +17,10 @@ import argparse import re import sys +import subprocess +import os +import datetime as datetime +import csv # A 组:已验证计入 Contributors 页面的 AI 身份。 CATALOG: list[dict] = [ @@ -274,30 +277,156 @@ def print_catalog() -> None: print(f"{entry['id']:<20} {trailer_for(entry):<58} {entry['note']}") +def _all_entries() -> list[dict]: + return CATALOG + BOT_ACCOUNTS + + +def resolve(args) -> list[dict]: + if args.all: + return _all_entries() + if args.bots: + return BOT_ACCOUNTS + return select([item.strip() for item in args.tools.split(",") if item.strip()]) + + +def _git(*cmd: str) -> str: + result = subprocess.run(["git", *cmd], text=True, capture_output=True) + if result.returncode != 0: + raise SystemExit("git %s failed: %s" % (" ".join(cmd), result.stderr.strip())) + return result.stdout.strip() + + +def _in_git_repo() -> bool: + result = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], text=True, capture_output=True) + return result.returncode == 0 and result.stdout.strip() == "true" + + +def cmd_apply(args) -> int: + """Create a branch and a commit carrying the selected co-author trailers.""" + if not _in_git_repo(): + raise SystemExit("Not inside a git repository. Run this inside the target repo.") + entries = resolve(args) + trailers = [trailer_for(e) for e in entries] + validate(trailers) + branch = args.branch or "contributor/add-%d" % len(trailers) + _git("checkout", "-b", branch) + csv_path = "activity/contributor-attributions.csv" + os.makedirs(os.path.dirname(csv_path), exist_ok=True) + header = not os.path.exists(csv_path) + with open(csv_path, "a", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + if header: + writer.writerow(["date", "agents", "trailer_count"]) + names = "; ".join(e["name"] for e in entries) + writer.writerow([datetime.date.today().isoformat(), names, len(trailers)]) + _git("add", csv_path) + subject = args.subject or "chore: attribute contributor identities" + message = subject + "\n\nAutomated attribution record; no substantive code change.\n\n" + "\n".join(trailers) + msg_file = os.path.join(os.getcwd(), ".git", "contributor-commit-message.txt") + with open(msg_file, "w", encoding="utf-8") as fh: + fh.write(message) + try: + _git("commit", "-F", msg_file) + finally: + if os.path.exists(msg_file): + os.remove(msg_file) + print("Created branch '%s' with a commit carrying %d co-author trailer(s)." % (branch, len(trailers))) + print("Next steps:") + print(" git push -u origin " + branch) + print(" Open a PR to the default branch and merge it (squash keeps the trailers).") + return 0 + + +def cmd_check(args) -> int: + """Report which identities appear in recent commits.""" + if not _in_git_repo(): + raise SystemExit("Not inside a git repository.") + ids = [item.strip() for item in args.tools.split(",") if item.strip()] + by_id = {e["id"]: e for e in _all_entries()} + missing = [i for i in ids if i not in by_id] + if missing: + raise SystemExit("Unknown tool ids: " + ", ".join(missing)) + targets = [by_id[i] for i in ids] + limit = args.limit or 100 + log = _git("log", "-n", str(limit), "--format=%H%x00%B") + present = {e["id"]: False for e in targets} + for block in log.split("\x00"): + for trailer in re.findall(r"(?im)^Co-Authored-By: (.+)$", block): + for e in targets: + if e["email"] in trailer: + present[e["id"]] = True + for e in targets: + print("%-20s %s (last %d commits)" % (e["id"], "PRESENT" if present[e["id"]] else "ABSENT", limit)) + return 0 + + +def cmd_remove(args) -> int: + """Remove identities from the in-repo catalog and script (stop offering them).""" + ids = [item.strip() for item in args.tools.split(",") if item.strip()] + script_path = os.path.abspath(__file__) + with open(script_path, encoding="utf-8") as fh: + script_src = fh.read() + removed = [] + for i in ids: + pattern = re.compile(r' \{\n "id": "' + re.escape(i) + r'",.*?\n \},\n', re.S) + new_src, n = pattern.subn("", script_src) + if n: + script_src = new_src + removed.append(i) + catalog_path = os.path.join(os.path.dirname(os.path.dirname(script_path)), "docs", "contributor-catalog.md") + if os.path.exists(catalog_path): + with open(catalog_path, encoding="utf-8") as fh: + catalog_src = fh.read() + for i in removed: + row = re.compile(r"^\| " + re.escape(i) + r" \|.*\n", re.M) + catalog_src, _ = row.subn("", catalog_src) + with open(catalog_path, "w", encoding="utf-8") as fh: + fh.write(catalog_src) + with open(script_path, "w", encoding="utf-8") as fh: + fh.write(script_src) + not_found = [i for i in ids if i not in removed] + if removed: + print("Removed from catalog and script: " + ", ".join(removed)) + if not_found: + print("Not found: " + ", ".join(not_found)) + print("Reminder: also update AGENT_CHECKIN_AUTHORS if the identity is used by the daily check-in.") + print("Historical commits still count on the Contributors page; only future usage stops.") + return 0 + + def main() -> int: - parser = argparse.ArgumentParser(description="Build Co-Authored-By trailers.") - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument("--list", action="store_true", help="list catalog entries") - group.add_argument("--tools", help="comma-separated ids (AI identities or bot accounts)") - group.add_argument("--bots", action="store_true", help="select all real GitHub bot accounts") - group.add_argument("--all", action="store_true", help="select every entry") - parser.add_argument( - "--commit-message", - help="optional commit subject; when set, print a full commit message", - ) + parser = argparse.ArgumentParser(description="Build Co-Authored-By trailers (self-serve for agents).") + parser.add_argument("--list", action="store_true", help="list catalog entries") + sel = parser.add_mutually_exclusive_group() + sel.add_argument("--tools", help="comma-separated ids (AI identities or bot accounts)") + sel.add_argument("--bots", action="store_true", help="select all real GitHub bot accounts") + sel.add_argument("--all", action="store_true", help="select every entry") + parser.add_argument("--apply", action="store_true", help="create a branch + commit with selected trailers (needs a git repo)") + parser.add_argument("--check", action="store_true", help="report which identities appear in recent commits (needs a git repo)") + parser.add_argument("--remove", action="store_true", help="remove identities from the in-repo catalog and script") + parser.add_argument("--commit-message", help="optional commit subject; when set, print a full commit message") + parser.add_argument("--branch", help="branch name for --apply") + parser.add_argument("--subject", help="commit subject for --apply") + parser.add_argument("--limit", type=int, help="how many recent commits --check scans (default 100)") args = parser.parse_args() if args.list: print_catalog() return 0 - if args.all: - entries = CATALOG + BOT_ACCOUNTS - elif args.bots: - entries = BOT_ACCOUNTS - else: - entries = select([item.strip() for item in args.tools.split(",") if item.strip()]) + if args.apply or args.check or args.remove: + if not (args.tools or args.bots or args.all): + parser.error("--apply/--check/--remove requires --tools/--bots/--all") + if args.apply: + return cmd_apply(args) + if args.check: + return cmd_check(args) + return cmd_remove(args) + + if not (args.tools or args.bots or args.all): + parser.error("one of --list/--tools/--bots/--all is required") + entries = resolve(args) trailers = [trailer_for(entry) for entry in entries] validate(trailers)