ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyGithub实战:用Python自动化你的GitHub工作流

PyGithub实战:用Python自动化你的GitHub工作流 如果你的日常工作里有“改一批仓库的README”“给一百个Issue补标签”“每周自动发一次Release”这类重复且痛苦的GitHub操作那PyGithub会是让你从点鼠标切换到写脚本的那道门。简单说PyGithub是GitHub REST API的Python封装官方API能做的事它基本都给你整理成了对象和方法代码写起来像在操作本地对象比直接requests手工拼接口省太多事。它适合自动化脚本开发者、DevOps工程师也适合任何想把GitHub操作批量脚本化的人。这篇文章不打算把API文档抄一遍我把实际项目里高频用到、踩过坑的部分挑出来按实操顺序讲清楚。1. 动手前先把三件事想明白1.1 为什么不用requests直接调API非要PyGithub很多人第一次接触PyGithub会问GitHub有现成REST API我用requests发请求不就行了吗确实行但代价是你要自己处理认证、分页、错误码、JSON嵌套结构以及每次API升级时的字段变动。比如拉一个仓库的Issue列表直接调API你会拿到一长串JSON里面嵌套着user、labels、milestone、assignees解析半天用PyGithub只需要repo.get_issues(stateopen)拿到的就是一个个Issue对象点属性就能用心智负担完全不同。PyGithub做的事情本质上是“把JSON变成对象把URL变成方法”。它的封装层把GitHub API的细节遮挡住让你专注于业务逻辑。比如一个Issue对象issue.title、issue.body、issue.created_at、issue.user.login属性名和直觉一致不需要记JSON字段大小写。这对脚本的长期维护非常关键代码是给人读的直接拼接口的代码过两周再看一眼扫过去全是字典下标很容易被绕晕。当然封装不是没有代价。GitHub偶尔会上线新接口PyGithub不一定第一时间跟进这时候要么等新版本要么临时用requests处理但这种情况很少。整体上PyGithub在自动化脚本里是值得默认选用的只有当你只需要调一两个冷门接口时才考虑直接用requests。1.2 安装与Token准备别把口令写死在代码里安装很简单pip install PyGithub即可。Python版本建议3.8以上太老的版本可能装不到最新版PyGithub老版本对某些新接口支持也不好。装完后在你的脚本里验证连接用最轻量的方式from github import Github g Github(你的token) print(g.get_user().login)能打印出用户名说明连接和认证都通了。这里的token不是密码是你的个人访问令牌。生成路径在GitHub页面右上角头像 - Settings - Developer settings - Personal access tokens。我的建议是生成Fine-grained token权限可以精确到某个仓库比传统classic token的安全边界清晰很多。选择权限时按实际需求勾选。比如你要改仓库文件就勾Contents的Read and write要管Issue就勾Issues的Read and write要创建Release勾Contents权限就行Release接口挂在Contents下。如果只是读公开仓库数据不需要任何权限用无权限的token即可。注意token生成后只会完整显示一次关掉页面就找不回来了只能重新生成。更关键的是永远不要把token硬编码进代码文件。我见过有人把token直接commit进仓库这是灾难必须用环境变量或密钥管理工具。export GITHUB_TOKENghp_xxxx脚本里这样取import os from github import Github g Github(os.environ[GITHUB_TOKEN])这样即使代码公开token也不泄露。你只要在部署环境里配置好环境变量就行。1.3 认证方式选择Token、Fine-grained Token和GitHub AppPyGithub的认证方式不止一种平时用个人token但团队自动化场景需要考虑更高阶的方式。三者差异用一个表格看更清楚认证方式适用场景权限粒度有效期建议Classic Personal Access Token个人小脚本、快速验证粗粒度按scope整体授权最长一年到期手动续临时用不建议作为长期方案Fine-grained Personal Access Token个人仓库、指定仓库自动化按仓库和权限类型精细控制可设置较短有效期日常脚本的首选GitHub App组织级自动化、多仓库服务账号最细按仓库列表授权自动轮换无需人工管理团队基础设施推荐GitHub App在PyGithub里通过GithubIntegration处理可以申请installation token适合跑在服务器上定时操作组织内仓库。不过配置成本也高需要注册App、配置私钥、指定仓库权限如果你的自动化只是自己仓库里折腾Fine-grained token足够。我的原则是能少管一件事就少管一件事小场景用token大场景直接上App。2. 核心对象上手从连接仓库到读写文件2.1 Github实例和第一层操作PyGithub的核心对象是Github它代表一次已认证的会话几乎所有操作都从它出发。创建实例后常见的顶层方法有这么几个from github import Github g Github(os.environ[GITHUB_TOKEN]) user g.get_user() # 当前用户 repo g.get_repo(octocat/Hello-World) # 任意仓库owner/repo格式 result g.search_repositories(PyGithub) # 搜索仓库我特别想提醒的是get_repo的参数格式。一定要传owner/repo这样的完整路径比如PyGithub/PyGithub。很多新手只写一个仓库名PyGithub结果报404还以为是权限问题。仓库名在GitHub上不唯一不同owner下可以同名必须带上owner才定位到唯一仓库。Github对象还有几个实用属性比如g.get_rate_limit()可以查看当前限流剩余量g.get_api_status()可以看GitHub服务状态排障时很有用。把这些顶层方法配合起来你可以快速拼装出很多自动化能力。2.2 文件内容读取与目录探测对仓库文件的操作是自动化里最常见的需求。PyGithub中读取文件内容的接口是repo.get_contents(path)但这里有个非常容易踩的坑这个接口可能返回两种不同类型的结果。content repo.get_contents(README.md) # 如果路径是文件返回 ContentFile 对象 if isinstance(content, list): # 如果路径是目录返回 PaginatedList里面每个元素也是 ContentFile pass当路径指向一个目录时返回的是列表里面是该目录下的条目当路径指向文件时返回单个ContentFile对象。所以写代码之前你必须要清楚你查的是文件还是目录。为了稳妥可以加个类型判断contents repo.get_contents() for item in contents: if item.type file: print(item.name, item.download_url) elif item.type dir: print(目录:, item.name)这个接口在递归遍历一个项目的文件树时特别好使。比如你想统计仓库里所有Python文件的代码量可以递归遍历每一层目录判断type再决定是否深入。注意目录路径不要以/开头比如src/utils而不是/src/utilsAPI对前导斜杠处理不一致会得到奇怪结果。2.3 创建、更新、删除文件牢记sha把文件内容读取出来还不够实际自动化里经常要改文件。PyGithub提供三个接口create_file、update_file、delete_file。它们都要求你指定提交信息message和文件路径真正容易出错的是参数细节。# 创建文件 repo.create_file( pathdocs/note.md, messagedocs: add note, content# Hello\n, branchmain, ) # 更新文件 old repo.get_contents(docs/note.md) repo.update_file( pathdocs/note.md, messagedocs: update note, content# Hello World\n, shaold.sha, branchmain, ) # 删除文件 old repo.get_contents(docs/note.md) repo.delete_file( pathdocs/note.md, messagedocs: remove note, shaold.sha, branchmain, )注意更新和删除文件都必须传sha这个值不是推导出来的而是当前文件对象上的sha属性。它的本质是当前文件内容在Git对象库中的blob SHAGit用这个值做并发冲突检测。如果你不先读取一遍拿sha直接更新API会返回422错误提示你sha不匹配。还有个细节content传的是字符串PyGithub内部会处理UTF-8编码所以中文内容直接往里塞就行不需要手动转码。但如果你的内容是从文件读出来的注意二进制文件不要用文本模式处理图片类的文件还是建议走GitHub API 的 sha 或直接使用git命令行。分支参数默认是仓库默认分支但自动化的项目最好显式传branchmain避免仓库默认分支从 master 改成 main 之后脚本静默跑错地方。3. Issue和PR的日常自动化3.1 Issue操作从列出、创建到批量更新管理Issue是PyGithub被用得最多的场景之一很适合用来做自动化工单整理。常用的操作链是列出指定条件下的Issue筛选再批量编辑或者评论。repo g.get_repo(owner/repo) issues repo.get_issues(stateopen, labels[bug]) for issue in issues: print(issue.number, issue.title, issue.user.login)get_issues支持state、labels等过滤参数返回的是一个可迭代对象。如果你要创建Issue一行代码搞定issue repo.create_issue( title发现了一个bug, body复现步骤……, labels[bug], assignees[username], )这里有个经验自动化脚本里如果没做去重很容易创建一堆重复Issue。我一般在创建前先搜索一遍判断是否已经存在相同标题的Issueexisting g.search_issues( frepo:{repo.full_name} in:title 发现了一个bug is:issue ) if existing.totalCount 0: print(已存在跳过创建)批量更新用的是issue.edit可以一次改标题、正文、状态、标签、负责人issue.edit( title新标题, stateclosed, labels[bug, triaged], assignees[username], )edit能同时处理多个字段比逐个调接口效率高很多。我实际处理过一种场景仓库里有几百个历史Issue都需要关闭并补充评论说明原因如果用网页操作一天都点不完用脚本遍历一遍几分钟搞定。这种重复劳动就是PyGithub发挥价值的地方。3.2 PR操作创建、状态检查到合并Pull Request的自动化同样高频。先看创建PRpr repo.create_pull( title进行中新功能开发, body这个PR改动包括……, headfeature/xxx, basemain, draftTrue, )创建时指定head分支和base分支注意这两个参数都是分支名head是源分支base是目标分支顺序不要搞反。draft参数让PR先以草稿状态创建方便在未完成时不触发大量通知。状态管理方面PR对象有state属性可以看它是open还是closed还能拿到是否已合并的merged属性。合并PR用pull.merge()可以指定合并方式pull.merge( commit_messagemerge from feature, merge_methodsquash, )merge_method支持merge、squash、rebase。对大部分仓库而言squash是把功能分支多个提交压缩成一个历史更干净rebase则保留线性提交记录。我建议看团队规范没有规范就选squash出错概率小。PR的代码审查也有对应接口比如提交审查结论pull.create_review( bodyLGTM可以合并, eventAPPROVE, )event可以是APPROVE、REQUEST_CHANGES、COMMENT。如果你写了一个自动发布脚本在发布前让机器人自动检查一遍PR的CI状态没问题就approve这正是典型的自动化工作流。3.3 评论与通知让机器人代替你说话很多时候自动化脚本不只是操作数据还需要在Issue或PR下留评论。比如每天晚上自动扫描过期Issue给它们贴上“waiting-reply”标签并评论催办。评论接口很简单issue.create_comment(这个Issue超过30天没有新回复请确认是否可以关闭。)PR评论有点不同你可以针对特定代码行评论但那种用得少。更为常用的做法是使用模板化的评论文案比如发布公告、标记版本、说明失败原因。为了让评论文案易于维护我习惯在脚本里单独定义一个comments模块把所有文案集中管理而不是散落在代码里到处是字符串。这些评论文案会影响用户在GitHub上的协作体验写得清楚点大家看着也不烦躁。4. 实战把PyGithub嵌进自动化流程4.1 搜索能力跨仓库找东西GitHub的搜索功能在PyGithub里也能直接用。这个能力在跨仓库审计时特别强大比如你想知道团队所有仓库里哪些代码调用了某个废弃接口results g.search_code(deprecated_func org:my-org) for item in results: print(item.repository.full_name, item.path)search_code对认证有要求必须登录且有的场景还需要Special permissions。如果返回403大概率是权限不够GitHub对代码搜索的鉴权特别严格。搜索仓库和搜索Issue相对友好repos g.search_repositories(stars:1000 language:python) issues g.search_issues(repo:owner/repo is:open is:issue)搜索接口最大的特点是分页和限流。它返回的不是一个纯粹的对象列表也是一个可迭代的分页结果迭代时会自动翻页。关于限流我会在第5章细说这里先提个醒搜索接口的速率限制比普通API严格很多写循环之前一定要估算请求量否则跑不到一半就被限流了。4.2 Release发布与标签管理替代点击发布发布Release是另一个高频自动化需求。用PyGithub创建Git标签和Release都很方便但记得顺序要对先打tag再创建Release。# 创建tag tag_ref repo.create_git_ref( refrefs/tags/v1.0.0, sharepo.get_branch(main).commit.sha, ) # 创建Release release repo.create_git_release( tagv1.0.0, namev1.0.0, message发布说明新增XX功能, draftTrue, )如果你要上传安装包或二进制文件用 release 的upload_asset方法release.upload_asset(dist/myapp.tar.gz)这里面有个坑创建tag时ref必须以refs/tags/开头不能只写v1.0.0。我经常看到有人在这里报错Bad request其实是参数格式不合规。还有一个问题是如果tag已经存在再创建会报422所以发布脚本里要先判断tag是否已存在。4.3 和GitHub Actions联动在CI里做轻量运维PyGithub最适合跑在GitHub Actions里因为GitHub每次运行都会注入一个临时的GITHUB_TOKEN环境变量你不需要自己管理token且权限自动限制在当前仓库内。一个常见的场景是定时任务检查仓库状态自动关闭过期Issue。name: auto-close-stale on: schedule: - cron: 0 2 * * * jobs: stale: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkoutv4 - name: Setup Python uses: actions/setup-pythonv5 with: python-version: 3.12 - name: Run script env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | pip install PyGithub python scripts/close_stale_issues.py脚本部分大致长这样from datetime import datetime, timezone, timedelta import os from github import Github g Github(os.environ[GITHUB_TOKEN]) repo g.get_repo(os.environ[GITHUB_REPOSITORY]) cutoff datetime.now(timezone.utc) - timedelta(days30) for issue in repo.get_issues(stateopen): if issue.created_at cutoff and not issue.pull_request: issue.create_comment(该Issue已超30天自动关闭。) issue.edit(stateclosed)这里有几个细节值得注意。一是GITHUB_REPOSITORY是Actions自动注入的环境变量格式正好是owner/repo可以直接传给get_repo。二是判断issue.pull_request能过滤掉PR因为PR在GitHub的Issue接口里也会出现。三是时间比较的时区问题GitHub返回的时间带UTC时区所以本地时间比较时也要用timezone.utc不能直接用datetime.now()否则会有8小时偏差。在CI里跑PyGithub权限能控制到仓库级但注意GITHUB_TOKEN被默认设置为只读权限如果你要写操作需要在Actions配置里单独设置permissions: contents: write或者给secrets配一个PAT。用PAT的秘密变量secrets.MY_PAT也行但就要自己去管过期了。我的建议是仓库内的自动化优先用GITHUB_TOKEN权限不够时再考虑PAT。5. 限流、分页、性能和工程化细节5.1 Rate Limit不控制请求量脚本必翻车GitHub API对所有请求有限流控制。认证状态下普通核心API每小时5000次请求未认证状态每小时只有60次。你可能会说5000次很多了但如果你写了双层for循环遍历几百个仓库下的所有Issue每个Issue又拉一遍评论那请求量是指数级涨上来的半小时就能打爆。PyGithub可以方便地查看当前限流余量rate g.get_rate_limit() print(rate.core.remaining, rate.core.limit) print(rate.search.remaining, rate.search.limit)这里有个重要区别core和search是两套独立限流。搜索API的限制比核心API更严普通接口能跑5000次搜索接口可能只允许10次每分钟。如果你的脚本里既有大量普通请求又有搜索请求要分别评估。我处理限流主要有几个手段一是脚本开头先打印限流余量做预估二是在不知遍历规模时显式控制循环次数三是在捕获到限流异常时退避重试。PyGithub内置了Retry和RespectRateLimit参数实际使用中简单的sleep策略在大多数场景是够用的import time from github import Github g Github(os.environ[GITHUB_TOKEN]) remaining g.get_rate_limit().core.remaining print(剩余请求:, remaining) # 如果剩余量很低先sleep等重置GitHub限流是滚动窗口制的不是固定的整点重置等一会儿它就会慢慢恢复。你在生产环境里跑大规模任务最好把任务拆小宁可多跑几次也不要一次梭哈。5.2 分页机制不是所有列表都一次给全PyGithub的列表类接口返回的是一个类似分页器的对象比如repo.get_issues()返回的不是Python列表而是PaginatedList。这意味着你取len(repo.get_issues())不会直接得到总数而是需要通过.totalCount获取。你遍历一个分页对象时PyGithub会在后台按页请求每页取一定数量所以下面的代码是安全的issues repo.get_issues(stateall) print(issues.totalCount) # 总数量 for issue in issues: print(issue.number)但如果你把分页对象强转成列表比如list(issues)那它会把所有页全部请求完数量大时耗时极长内存也有压力。我的建议是永远不要全量转list用切片取所需部分才是正确姿势。first_10 repo.get_issues(stateopen)[:10]切片操作在分页对象上是带索引的内部会尽量少请求。如果真想遍历全部数据用一个计数器控制总的循环次数并实时打印进度避免脚本卡住了你还没察觉。PaginatedList还有一个冷知识如果你在遍历中途调用了创建、删除操作导致列表里的条目数变化迭代行为可能不同于预期。稳妥的做法是先收集需要的条目标识比如issue.number再在第二轮循环里去操作不要在同一个循环里既遍历又增删。5.3 异常处理模板让脚本能自愈网络请求怎么可能不出错。PyGithub的异常体系集中在github.GithubException.GithubException下常见有BadCredentialsException401、RateLimitExceededException403/429、UnknownObjectException404等。写健壮脚本时只捕获一个大类往往不够我会按状态码精细化处理from github import GithubException try: repo g.get_repo(owner/repo) except GithubException as e: if e.status 404: print(仓库不存在或无权访问) elif e.status in (401, 403): print(认证失败或权限不足) elif e.status 429: print(触发限流) else: print(f未知异常: {e.status} {e.data})这个模板帮我省了不少事尤其是在多个仓库批量操作时某个仓库失败不应该让整个脚本崩溃。你可以把异常捕获放在单个仓库的处理函数内部而不是整个for循环外面这样某个仓库出错了循环还能继续跑其他的。另外一个实用技巧是给接口调用加一个装饰器统一做重试和日志记录这个在长任务里特别见效。6. 踩坑实录我遇到过的那些鬼问题6.1 常见报错排查表把我在生产环境里实打实踩过的坑整理成一个速查表排查问题时直接对着查现象可能原因解决办法401 Bad credentialstoken过期、拼错、含换行符重新生成token确认环境变量没多余空格403 Resource not accessibleFine-grained token权限没勾够检查token的repository permissions403 Rate limit exhausted请求量超限流查看rate limit余量等待或降频404 Not Found仓库名写错、私有仓库无授权确认owner/repo格式检查token权限409 Conflict目标分支为保护分支直接推送被拒改用PR流程或者临时解除保护422 Validation Failedsha不正确或内容校验失败更新文件前重新get_contents取最新sha遍历中途请求变慢未控制分页请求量过大用totalCount预估拆分任务批次执行排查的顺序我有固定套路先看错误码401/403先怀疑认证与权限404多半是仓库路径问题429就是限流422则是参数校验没过。状态码是最快的定位线索不要一上来就翻日志堆。6.2 几个特别隐蔽的坑有些问题不报错但结果就是不对这类坑最讨厌。我遇到过的有这么几个时间字段的时区问题。GitHub返回的时间都是UTC时间且带时区信息。你用issue.created_at和本地时间比较不转换时区就会差8小时。判断“是否超过30天未更新”要用datetime.now(timezone.utc)我已经见到好几个脚本因为这个问题提前关闭了活跃Issue。PR在Issue接口里混入的问题。repo.get_issues()默认包含PR。如果你统计Issue数量不把issue.pull_request过滤掉数字会偏大。所以前面那个close stale脚本里我特意加了not issue.pull_request判断。标签名的大小写和空格问题。GitHub标签名是区分大小写的而且允许带空格。你在创建标签时用的名字和后续搜索时用的名字必须完全一致差一个空格都匹配不上。如果用labels[bug]创建Issue搜索时也必须用完全一致的标签名。还有仓库默认分支迁移的问题。老仓库默认分支是master新仓库是main。如果你的脚本里写死了branchmaster在某些新仓库上会422。我现在的习惯是读取仓库的default_branch属性动态决定目标分支default_branch repo.default_branch这个方法在自动化脚本里几乎通用省去很多硬编码分支名的麻烦。6.3 工程化建议从能跑到好维护最后把工程化层面的经验也分享一下。我写PyGithub脚本最终都会沉淀成一个小的维护章程这几点很管用第一不要把token写进代码也不要把token写进shell历史。用环境变量部署到服务器时用一个.env文件统一管理并确保这个文件被gitignore。第二脚本参数尽量用配置文件或命令行参数不要硬编码仓库名和分支。比如要处理的仓库列表放在repos.txt里每次换目标仓库不用改代码。第三操作类脚本要有幂等性。重新跑一遍不应该产生重复副作用。比如创建Issue前先搜索创建Release前先检查tag是否存在这些都是用几行代码换来的稳定性。第四日志比print重要。小脚本用print没问题但一旦脚本跑在cron或者Actions里输出会丢失、难以排查。我习惯用logging模块输出到文件记录每个操作的对象名、动作和结果排查问题时能省大量时间。结尾一点实践体会写在这里我把仓库的日常维护从“天天点网页”改成“定时脚本”之后最明显的感受是自己终于有底气处理大型仓库了几百个Issue、几十个分支、每周发版这些事不再需要专门抽出时间来做。PyGithub这个库的价值不在单点功能多强大而在于它把所有散落的API能力整合成了同一个开发体验让“写脚本操作GitHub”这件事真正可用。最后再分享一个小技巧如果你的脚本要在多台机器上跑建议在入口处统一打印当前PyGithub版本和API限流余量线上排查问题时这两个信息能快速帮你定位是代码问题、环境问题还是接口问题。自动化运维这条路上先能把日志看明白再谈更复杂的编排。
返回列表