Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
30 changes: 30 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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`。
71 changes: 71 additions & 0 deletions docs/add-contributors-with-agent.md
Original file line number Diff line number Diff line change
@@ -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-<n>` → 追加 `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 同为 [email protected],只保留一个)。
- 不要伪造身份:只使用目录里列出的、GitHub 能识别的真实邮箱。
- 本仓库还提供 `AGENTS.md` / `CLAUDE.md`,按你的 Agent 类型读对应文件即可;上面的命令在 git + python 环境通用,与具体 Agent 无关。
12 changes: 12 additions & 0 deletions docs/contributor-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,25 @@ 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"
```

把生成的 `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` 到目标仓库。
Expand Down
171 changes: 150 additions & 21 deletions scripts/build_contributors.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@
- 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

import argparse
import re
import sys
import subprocess
import os
import datetime as datetime
import csv

# A 组:已验证计入 Contributors 页面的 AI 身份。
CATALOG: list[dict] = [
Expand Down Expand Up @@ -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)

Expand Down