1. 为什么我要自己造一个自动提 PR 的轮子
团队里用 Azure DevOps 做代码托管已经两年多了,日常开发流程里最烦的其实不是写代码,而是每次改完一堆文件之后那套机械动作:切分支、写 commit message、push、打开浏览器、找到目标仓库、点 New Pull Request、填标题、填描述、选 reviewer、关联工作项。一套下来少说五分钟,多的时候十分钟就没了,而且这十分钟里你脑子是空转的,纯纯的体力活。
一开始我也试过用现成的脚本糊一个,比如写个 bash 把 git 命令串起来,再调 Azure DevOps 的 REST API 创建 PR。能用,但问题很明显:脚本是死的,参数全靠命令行传,稍微复杂一点的需求(比如根据改动文件自动推断 reviewer、根据分支名自动关联工作项)就得改脚本。后来 MCP(Model Context Protocol)这个概念火起来,我意识到这东西天生适合干这种"把多个工具串起来、由模型决定调用顺序"的活。MCP 本质上是一个标准化的协议,让模型能够以统一的方式调用外部工具,而 Skill 则是把这些工具能力封装成模型可以理解和编排的单元。
所以这个项目的目标就很明确了:做一个 MCP 工具,把 Git 的本地操作和 Azure DevOps 的远端 API 串起来,让模型通过 Skill 编排,实现"我说一句'把这个改动提个 PR',它自己把剩下的事全干完"。关键词里的 MCP、Git、Azure DevOps、PR、Skill 这五个词,基本就是这个项目的全部骨架。这篇文章我会把整个实现过程拆开讲,包括我踩过的坑、为什么这么设计、以及一些文档里不会写的细节。
先说清楚适合谁看:如果你已经在用 Azure DevOps 或者类似的代码平台,日常有大量重复的提交 PR 操作,并且对 MCP 有基本了解(哪怕只是听说过),那这篇能直接抄作业。如果你完全没接触过 MCP,也没关系,我会在讲实现的时候把必要的背景补上,但你需要对 Git 的基本操作和 REST API 的调用方式有概念。
2. 拆解需求:这个 MCP 工具到底要解决哪几件事
2.1 把"提 PR"这个动作拆成原子能力
很多人一上来就想做一个"一键提 PR"的大工具,我觉得这是错的。MCP 的设计哲学是提供细粒度的工具,让模型去编排,而不是做一个黑盒。所以我先把整个流程拆成了几个原子操作:
- 检查工作区状态:当前在哪个分支、有没有未提交的改动、有没有未跟踪的文件。
- 创建并切换分支:根据命名规范生成分支名,比如
feature/xxx或fix/xxx。 - 暂存与提交:把改动 add 进来,生成符合规范的 commit message。
- 推送到远端:push 到 origin,并设置 upstream。
- 创建 PR:调用 Azure DevOps REST API,填标题、描述、源分支、目标分支、reviewer。
- 关联工作项:如果分支名或 commit message 里有工作项 ID,自动关联。
这六个操作每一个都做成独立的 MCP tool,模型可以根据实际情况决定调用哪些、跳过哪些。比如工作区是干净的,那就不需要 commit,直接 push 就行;比如分支已经存在,那就不需要创建。
2.2 为什么不让模型直接调 git 命令
有人会问,既然模型能执行 shell 命令,为什么不直接让它跑git commit -m "xxx"?我试过,问题在于不可控。模型生成的命令你没法保证它每次都正确,尤其是涉及路径、分支名、特殊字符的时候,一个引号没处理好就出问题。而且直接执行 shell 命令没有结构化输出,模型拿到一堆文本还得自己解析,容易出错。
做成 MCP tool 的好处是:输入输出都是结构化的 JSON,模型知道每个字段是什么含义,调用之前可以先校验参数,调用之后能拿到明确的结果。比如create_branch这个 tool,输入是{branch_name, base_branch},输出是{success, branch_name, message},模型一看就知道成没成。
2.3 Skill 在这里扮演什么角色
Skill 我的理解是"给模型看的使用说明书 + 编排模板"。MCP tool 是能力,Skill 是告诉模型"在什么场景下、按什么顺序、用哪些能力"。比如我写了一个叫auto-pr的 Skill,里面描述了标准流程:先检查状态,再决定是否创建分支,再提交,再推送,最后创建 PR。模型读到这个 Skill 之后,就知道遇到"提 PR"的请求该按什么套路走。
这样做的好处是,流程逻辑不写在代码里,而是写在 Skill 的描述里。以后流程要改,改 Skill 就行,不用重新编译 MCP server。这也是我觉得 MCP + Skill 这套组合最有价值的地方:能力与编排分离。
3. 环境搭建:Git 与 Azure DevOps 的对接准备
3.1 Git 侧的配置细节
Git 的安装和配置网上教程一大堆,我就不重复了,只说几个容易忽略但很关键的点。
第一个是用户身份。MCP server 执行 git commit 的时候,用的是当前系统用户的 git config。如果你在 CI 环境或者容器里跑,很可能没配 user.name 和 user.email,commit 会直接失败。我的做法是在 MCP server 启动时先检查这两个配置,没有的话就用环境变量兜底:
git config --global user.name "${GIT_USER_NAME:-mcp-bot}" git config --global user.email "${GIT_USER_EMAIL:-mcp-bot@example.com}"第二个是凭证管理。push 到远端需要认证,Azure DevOps 支持 PAT(Personal Access Token)或者 SSH key。我推荐用 PAT,因为它在 HTTP 层面工作,配置简单,而且可以精细控制权限范围。PAT 的权限只需要勾选Code (read & write)就够了,不要给多余的权限。
第三个是换行符。跨平台协作的时候 CRLF 和 LF 混用会导致整个文件显示为改动,非常烦。建议在仓库根目录放一个.gitattributes,强制统一:
* text=auto eol=lf *.bat text eol=crlf3.2 Azure DevOps 的 PAT 与 API 端点
Azure DevOps 的 REST API 基地址格式是:
https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}/pullrequests?api-version=7.0创建 PR 用 POST,请求体大概长这样:
{ "sourceRefName": "refs/heads/feature/xxx", "targetRefName": "refs/heads/main", "title": "PR 标题", "description": "PR 描述", "reviewers": [ { "id": "reviewer-uuid" } ], "workItemRefs": [ { "id": "12345" } ] }注意几个坑:sourceRefName和targetRefName必须带refs/heads/前缀,少了会报错。reviewers里的id是用户的 UUID,不是邮箱也不是显示名,这个 UUID 需要通过 Graph API 查。workItemRefs关联工作项的时候,工作项 ID 是字符串不是数字。
PAT 的认证方式是 HTTP Basic Auth,用户名随便填(通常填个pat或者空字符串),密码填 PAT:
curl -u ":$PAT" https://dev.azure.com/...3.3 MCP Server 的技术选型
MCP server 我选了 Node.js + TypeScript,原因是官方 SDK 对 TypeScript 支持最好,而且 JSON 处理方便。核心依赖就两个:@modelcontextprotocol/sdk和simple-git(封装 git 命令)。HTTP 请求用 Node 18 自带的 fetch,不需要额外装 axios。
目录结构大概是这样:
mcp-auto-pr/ ├── src/ │ ├── index.ts # MCP server 入口 │ ├── tools/ │ │ ├── git-status.ts │ │ ├── create-branch.ts │ │ ├── commit.ts │ │ ├── push.ts │ │ └── create-pr.ts │ ├── azure/ │ │ └── client.ts # Azure DevOps API 封装 │ └── config.ts # 配置读取 ├── skills/ │ └── auto-pr.md # Skill 描述文件 └── package.json4. 逐个实现 MCP Tool:从 git status 到 create PR
4.1 git_status:一切判断的起点
这个 tool 的作用是让模型知道当前仓库的状态。返回的信息包括:当前分支、是否有未提交改动、未跟踪文件列表、与远端的差异。
import { simpleGit } from 'simple-git'; export async function gitStatus(repoPath: string) { const git = simpleGit(repoPath); const status = await git.status(); const branch = await git.revparse(['--abbrev-ref', 'HEAD']); return { currentBranch: branch.trim(), isClean: status.isClean(), modified: status.modified, notAdded: status.not_added, created: status.created, deleted: status.deleted, ahead: status.ahead, behind: status.behind, }; }这里有个细节:status.isClean()只判断已跟踪文件的改动,未跟踪文件不算。所以我在返回里单独列了not_added,让模型自己决定要不要处理。实际用下来,模型看到有未跟踪文件时,会主动问用户"这些新文件要不要一起提交",这个交互体验挺好的。
4.2 create_branch:命名规范要在这里落地
分支命名我定了一套规范:{type}/{workitem-id}-{short-desc},比如feature/12345-add-login。type 可以是 feature、fix、hotfix、chore。
export async function createBranch( repoPath: string, branchName: string, baseBranch: string = 'main' ) { const git = simpleGit(repoPath); // 先切到 base 分支并拉最新 await git.checkout(baseBranch); await git.pull('origin', baseBranch); // 创建并切换到新分支 await git.checkoutLocalBranch(branchName); return { success: true, branchName, baseBranch }; }为什么要先切到 base 再 pull?因为如果直接在当前分支创建新分支,新分支会继承当前分支的状态,可能包含未推送的提交。先切到 base 并拉最新,能保证新分支是从最新的主干切出来的,减少后续合并冲突。
注意:如果工作区有未提交的改动,
checkout会失败或者把改动带过去。所以这个 tool 应该在git_status确认工作区干净之后再调用,或者在内部先做 stash。
4.3 commit:message 生成与暂存策略
commit 这个 tool 我设计成接收一个 message 参数,而不是让模型自己拼 git 命令。暂存策略默认是git add -A,但支持传入文件列表做精细控制。
export async function commit( repoPath: string, message: string, files?: string[] ) { const git = simpleGit(repoPath); if (files && files.length > 0) { await git.add(files); } else { await git.add('.'); } const result = await git.commit(message); return { success: true, commitHash: result.commit, summary: result.summary, }; }commit message 的规范我建议用 Conventional Commits,格式是type(scope): subject。这个规范的好处是机器可读,后续可以自动生成 changelog。我在 Skill 里明确告诉模型要按这个格式生成 message,实测下来模型生成的质量还不错,比如feat(auth): add JWT token refresh logic。
4.4 push:处理 upstream 和冲突
push 看起来简单,其实坑不少。最常见的是新分支没有 upstream,直接git push会报错。所以要用git push -u origin branchName。
export async function push( repoPath: string, branchName: string, setUpstream: boolean = true ) { const git = simpleGit(repoPath); const options = setUpstream ? ['-u', 'origin', branchName] : ['origin', branchName]; const result = await git.push(options); return { success: true, pushed: result.pushed, branch: branchName, }; }另一个坑是远端已经有同名分支且历史不一致。这种情况 push 会被拒绝,需要 force push。但 force push 很危险,我坚决不在 tool 里提供这个选项。如果遇到这种情况,让模型告诉用户手动处理,这是安全底线。
4.5 create_pr:Azure DevOps API 的封装
这是整个项目最核心的 tool。前面几个都是本地操作,这个才是真正跟 Azure DevOps 打交道的地方。
export async function createPullRequest(params: { organization: string; project: string; repositoryId: string; sourceBranch: string; targetBranch: string; title: string; description: string; reviewerIds?: string[]; workItemIds?: string[]; pat: string; }) { const url = `https://dev.azure.com/${params.organization}/${params.project}/_apis/git/repositories/${params.repositoryId}/pullrequests?api-version=7.0`; const body = { sourceRefName: `refs/heads/${params.sourceBranch}`, targetRefName: `refs/heads/${params.targetBranch}`, title: params.title, description: params.description, reviewers: (params.reviewerIds || []).map(id => ({ id })), workItemRefs: (params.workItemIds || []).map(id => ({ id })), }; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${Buffer.from(`:${params.pat}`).toString('base64')}`, }, body: JSON.stringify(body), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`创建 PR 失败: ${response.status} ${errorText}`); } const data = await response.json(); return { success: true, pullRequestId: data.pullRequestId, url: `https://dev.azure.com/${params.organization}/${params.project}/_git/${params.repositoryId}/pullrequest/${data.pullRequestId}`, }; }这里有个细节值得说:reviewer 的 UUID 怎么来。Azure DevOps 的 Graph API 可以按邮箱或显示名查用户:
GET https://vssps.dev.azure.com/{organization}/_apis/graph/users?api-version=7.0返回的是所有用户列表,需要自己过滤。我建议在配置里维护一个"邮箱 → UUID"的映射表,避免每次调用都查一遍。这个映射表可以定期更新,或者第一次查完之后缓存起来。
5. Skill 编排:让模型知道按什么顺序干活
5.1 Skill 描述文件的结构
Skill 文件我用 Markdown 写,因为模型读 Markdown 最自然。内容分三部分:适用场景、可用工具、标准流程。
# Skill: auto-pr ## 适用场景 当用户要求"提交 PR"、"创建合并请求"、"把这个改动提上去"时使用。 ## 可用工具 - git_status: 检查工作区状态 - create_branch: 创建并切换分支 - commit: 暂存并提交 - push: 推送到远端 - create_pr: 在 Azure DevOps 创建 PR ## 标准流程 1. 调用 git_status,确认当前分支和工作区状态。 2. 如果工作区不干净,根据改动内容生成 commit message,调用 commit。 3. 如果当前在 main 或 develop 等保护分支,调用 create_branch 创建新分支。 4. 调用 push 推送当前分支。 5. 调用 create_pr,标题和描述根据 commit 内容生成。 6. 如果分支名或 commit message 里有工作项 ID,在 create_pr 时关联。5.2 为什么流程要写在 Skill 里而不是代码里
这是我在实际使用中体会最深的一点。一开始我把流程硬编码在 MCP server 里,做一个auto_pr的大 tool,内部按顺序调所有子操作。结果发现两个问题:
第一,灵活性差。有时候用户只想提交不想提 PR,有时候分支已经存在不需要创建,硬编码的流程没法处理这些变体。第二,调试困难。流程出问题的时候,你不知道是哪一步错了,因为所有步骤都包在一个 tool 里。
改成 Skill 编排之后,模型会根据实际情况决定调用哪些 tool。比如它看到工作区干净、分支已存在、只是没推送,就会跳过 commit 和 create_branch,直接 push 然后 create_pr。这种灵活性是硬编码给不了的。
5.3 处理模型"不听话"的情况
Skill 毕竟是自然语言描述,模型不一定每次都严格照做。我遇到过几种情况:
- 模型跳过了 git_status 直接 commit,结果在有冲突的文件上操作失败。
- 模型生成的 commit message 不符合 Conventional Commits 格式。
- 模型在保护分支上直接 commit 而没有创建新分支。
针对这些,我的做法是在 tool 层面加校验。比如committool 内部先检查当前分支是不是保护分支,如果是就返回错误,提示模型先创建分支。create_prtool 校验 sourceBranch 和 targetBranch 不能相同。这样即使模型编排出错,tool 层面也能兜住。
提示:Skill 是"软约束",tool 的参数校验是"硬约束"。两者结合才能保证流程可靠。
6. 实测中踩过的坑与排查过程
6.1 push 报 "ssh认证失败" 的排查
第一次跑通流程的时候,push 一直失败,报的是 SSH 认证错误。我一开始以为是 key 没配好,折腾了半天。后来发现根本原因是:仓库的 remote URL 是 SSH 格式,但我的 PAT 是给 HTTP 用的。
排查过程是这样的:先git remote -v看 remote URL,发现是git@ssh.dev.azure.com:...这种格式。SSH 格式走的是 SSH key 认证,跟 PAT 没关系。解决办法有两个:要么配 SSH key,要么把 remote URL 改成 HTTPS 格式。
我选了后者,因为 PAT 管理起来更方便:
git remote set-url origin https://dev.azure.com/{org}/{project}/_git/{repo}改完之后 push 就正常了。这个坑的教训是:PAT 和 SSH key 是两套认证体系,不能混用。用 PAT 就必须走 HTTPS。
6.2 PR 创建成功但 reviewer 没加上
有几次 PR 创建成功了,但 reviewer 列表是空的。查了 API 返回,发现 reviewers 字段被忽略了。原因是传的 UUID 格式不对。Azure DevOps 的 reviewer id 必须是完整的 UUID 格式,比如a1b2c3d4-e5f6-7890-abcd-ef1234567890,少一段或者格式不对都会被静默忽略。
排查方法是在创建 PR 之后,再调一次 GET 接口查 PR 详情,看 reviewers 字段是不是空的。如果是空的,就说明 id 有问题。我后来在代码里加了一个校验,UUID 必须匹配正则^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$,不匹配的直接报错,不静默忽略。
6.3 工作项关联失败的几种情况
关联工作项也踩过坑。有一次传了工作项 ID,但 PR 创建后工作项没关联上。查下来发现是工作项 ID 传成了数字类型,而 API 要求字符串。改成字符串之后就好了。
还有一种情况是工作项不存在或者没权限访问。这种 API 会返回 400,但错误信息很模糊,只说 "Invalid work item reference"。我的处理方式是在关联之前先调工作项的查询接口确认存在,不存在就跳过关联并在返回里提示。
6.4 大文件导致的 push 超时
有一次提交了一个几十 MB 的二进制文件,push 卡了很久最后超时。这个问题的根源是仓库里不应该有大文件。我的处理方式是在committool 里加一个文件大小检查,超过 10MB 的文件给出警告,超过 50MB 直接拒绝提交。
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB // 在 add 之前遍历文件,检查大小这个阈值可以根据团队实际情况调整。如果确实需要管理大文件,应该用 Git LFS,而不是直接提交到仓库。
7. 一些让工具更好用的经验补充
7.1 配置文件的设计
我把所有环境相关的配置抽到一个config.json里,包括 organization、project、repositoryId、PAT、reviewer 映射表、保护分支列表。这样换一个项目只需要改配置,不用动代码。
{ "organization": "my-org", "project": "my-project", "repositoryId": "my-repo", "pat": "${AZURE_DEVOPS_PAT}", "protectedBranches": ["main", "develop", "release/*"], "reviewerMap": { "alice@example.com": "uuid-1", "bob@example.com": "uuid-2" } }PAT 用环境变量占位符,避免明文写在配置文件里。启动时做一次替换。
7.2 日志与可观测性
MCP server 跑在后台,出问题的时候如果没日志会很难查。我在每个 tool 的入口和出口都加了日志,记录输入参数、执行结果、耗时。日志写到文件里,按天切割。
logger.info('create_pr called', { sourceBranch, targetBranch, title }); const start = Date.now(); // ... 执行 logger.info('create_pr completed', { pullRequestId, duration: Date.now() - start });有了这些日志,排查问题的时候直接看日志就行,不用复现。实测下来,90% 的问题看日志就能定位。
7.3 幂等性处理
网络抖动或者超时的时候,模型可能会重试。如果 create_pr 被调用了两次,会创建两个重复的 PR。所以我在 create_pr 里加了幂等检查:先查一下源分支有没有已经打开的 PR,有的话直接返回那个 PR 的信息,不重复创建。
GET .../pullrequests?searchCriteria.sourceRefName=refs/heads/{branch}&searchCriteria.status=active这个查询返回当前分支所有活跃的 PR,如果有就直接复用。这样即使重试也不会产生重复 PR。
7.4 关于 Skill 的版本管理
Skill 文件我放在 git 仓库里跟代码一起管理,每次改 Skill 都走正常的 PR 流程。这样做的好处是 Skill 的变更可追溯,出问题可以回滚。另外我在 Skill 文件头部加了一个版本号,方便排查"模型用的是哪个版本的 Skill"。
<!-- skill-version: 1.2.0 -->模型读到这个版本号,在日志里也会带上,排查问题的时候能对应上。
8. 关于这套方案的一些个人体会
这套东西跑了大半年,团队里现在提 PR 基本都是一句话的事。我最大的体会是:MCP + Skill 这套组合的价值不在于"自动化",而在于"可编排"。传统的脚本自动化是线性的、固定的,而 MCP 让模型根据上下文动态决定调用哪些能力,这才是它真正区别于脚本的地方。
另一个体会是安全边界一定要在 tool 层面守住。Skill 是给模型看的,模型可能理解偏差,但 tool 的参数校验是代码,是确定性的。force push、删除分支、修改保护分支这些危险操作,我坚决不在 tool 里提供,从源头上杜绝误操作。
最后说一个我觉得后续可以扩展的方向:现在 reviewer 是根据配置文件静态映射的,其实可以根据改动的文件路径动态推断。比如改了src/auth/下的文件,就自动找 auth 模块的负责人。这个逻辑可以做成一个独立的 tool,让 Skill 在创建 PR 之前调用。不过这个需要维护一份"路径 → 负责人"的映射,团队规模小的时候可能不划算,规模大了就很有价值。