From 1ba316aaa1bae6e94543e5e6689997083a1e8be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9B=D1=8F=D1=89=D1=83=D0=BA?= <40496434+prog-time@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:32:01 +0300 Subject: [PATCH 1/2] =?UTF-8?q?issues-1=20|=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20create=5Fpull=5Frequest=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20PR?= =?UTF-8?q?=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Octokit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ------------------------------ Files: Changed: CLAUDE.md Changed: INSTRUCTIONS.md Changed: README.md Changed: src/router.ts Added: src/tools/createPullRequest.ts Changed: tests/src/router.test.ts Added: tests/src/tools/createPullRequest.test.ts --- CLAUDE.md | 10 +- INSTRUCTIONS.md | 26 ++- README.md | 21 +- src/router.ts | 2 + src/tools/createPullRequest.ts | 108 +++++++++ tests/src/router.test.ts | 5 +- tests/src/tools/createPullRequest.test.ts | 259 ++++++++++++++++++++++ 7 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 src/tools/createPullRequest.ts create mode 100644 tests/src/tools/createPullRequest.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 789e7ec..e3ddb6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,8 @@ mcp-github-issues/ │ ├── listIssues.ts │ ├── fetchIssue.ts │ ├── addComment.ts -│ └── updateIssue.ts +│ ├── updateIssue.ts +│ └── createPullRequest.ts ├── tests/ # Vitest unit tests ├── logs/ # Server logs (gitignored) ├── projects.yaml # Project config (gitignored, use projects.yaml.example) @@ -61,7 +62,7 @@ const server = new McpServer({ name: "mcp-github-issues", version: "1.2.0" }); await server.connect(new StdioServerTransport()); ``` -## Tools (6 total) +## Tools (7 total) ### 1. `list_projects` - No input. @@ -90,6 +91,11 @@ await server.connect(new StdioServerTransport()); - Input: `project`, `issue`, `state` (optional), `title` (optional), `assignee` (optional, null to remove), `add_labels` (optional), `remove_labels` (optional) - Updates state, title, assignee, or labels of an existing Issue. +### 7. `create_pull_request` +- Input: `project`, `title`, `head` (branch or `owner:branch`), `base` (default: `main`), `body` (optional), `draft` (default: `false`), `maintainer_can_modify` (optional) +- Creates a GitHub Pull Request via Octokit (`pulls.create`). +- Returns the PR number and `html_url`. + ## Issue Body Template `publish_issue` generates the issue body in this shape: diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index cf901e9..e507eb1 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -149,7 +149,7 @@ claude mcp add -s user -- mcp-github-issues /путь/к/mcp-github-issues/mcp.s ## Доступные инструменты (Tools) -Сервер предоставляет 6 инструментов, которые AI-ассистент может вызывать в диалоге. +Сервер предоставляет 7 инструментов, которые AI-ассистент может вызывать в диалоге. ### list_projects @@ -272,6 +272,27 @@ claude mcp add -s user -- mcp-github-issues /путь/к/mcp-github-issues/mcp.s **Пример использования:** > «Закрой Issue #42 в backend и добавь лейбл "done"» +### create_pull_request + +Создаёт GitHub Pull Request для настроенного проекта одной командой. + +**Параметры:** + +| Параметр | Тип | Обязательный | Описание | +|-----------------------|---------|:------------:|-----------------------------------------------------------------------------------| +| project | string | да | Имя проекта из projects.yaml | +| title | string | да | Заголовок Pull Request | +| head | string | да | Ветка с изменениями (например, `feature/my-feature` или `fork-owner:branch`) | +| base | string | нет | Целевая ветка для merge (по умолчанию: `main`) | +| body | string | нет | Описание PR (поддерживает Markdown) | +| draft | boolean | нет | Создать как черновик (по умолчанию: `false`) | +| maintainer_can_modify | boolean | нет | Разрешить мейнтейнерам редактировать ветку PR (актуально для форков) | + +Возвращает номер PR и ссылку на него (`html_url`). + +**Пример использования:** +> «Создай PR в backend: смержи ветку feature/add-email-validation в main. Заголовок: "Add email validation on registration"» + --- ## Типичный рабочий процесс @@ -319,7 +340,8 @@ mcp-github-issues/ │ ├── fetchIssue.ts │ ├── publish.ts │ ├── addComment.ts -│ └── updateIssue.ts +│ ├── updateIssue.ts +│ └── createPullRequest.ts ├── tests/ # Тесты (Vitest) ├── logs/ # Логи сервера ├── projects.yaml # Конфигурация проектов diff --git a/README.md b/README.md index 5287bce..9ed412d 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,24 @@ Updates an existing GitHub Issue: change state, title, assignee, or labels. --- +### `create_pull_request` + +Creates a new GitHub Pull Request for a configured project. + +| Parameter | Type | Required | Description | +|-------------------------|-----------|----------|-------------------------------------------------------------------------------------| +| `project` | `string` | yes | Project name from `projects.yaml` | +| `title` | `string` | yes | Pull request title | +| `head` | `string` | yes | Branch with changes (e.g. `feature/my-feature` or `fork-owner:branch`) | +| `base` | `string` | no | Target branch to merge into (default: `main`) | +| `body` | `string` | no | PR description (raw Markdown) | +| `draft` | `boolean` | no | Create as draft PR (default: `false`) | +| `maintainer_can_modify` | `boolean` | no | Allow maintainers to edit the PR head branch (applies to cross-repo PRs only) | + +Returns the PR number and `html_url`. + +--- + ## Typical conversation ``` @@ -329,7 +347,8 @@ mcp-github-issues/ │ ├── fetchIssue.ts │ ├── publish.ts │ ├── addComment.ts -│ └── updateIssue.ts +│ ├── updateIssue.ts +│ └── createPullRequest.ts ├── tests/ │ └── src/ │ ├── config.test.ts diff --git a/src/router.ts b/src/router.ts index 0153416..eef7a73 100644 --- a/src/router.ts +++ b/src/router.ts @@ -5,6 +5,7 @@ import * as fetchIssue from "./tools/fetchIssue.js"; import * as listIssues from "./tools/listIssues.js"; import * as addComment from "./tools/addComment.js"; import * as updateIssue from "./tools/updateIssue.js"; +import * as createPullRequest from "./tools/createPullRequest.js"; export function registerAllTools(server: McpServer): void { listProjects.register(server); @@ -13,4 +14,5 @@ export function registerAllTools(server: McpServer): void { listIssues.register(server); addComment.register(server); updateIssue.register(server); + createPullRequest.register(server); } diff --git a/src/tools/createPullRequest.ts b/src/tools/createPullRequest.ts new file mode 100644 index 0000000..a609896 --- /dev/null +++ b/src/tools/createPullRequest.ts @@ -0,0 +1,108 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { getProject, getOctokit } from "../config.js"; +import { logger } from "../logger.js"; + +// ─── schema ────────────────────────────────────────────────────────────────── + +export const CreatePullRequestInput = z.object({ + project: z.string().describe("Project name from projects.yaml"), + title: z.string().describe("Pull request title"), + head: z + .string() + .describe( + "Name of the branch where changes are implemented (e.g. 'feature/my-feature' or 'fork-owner:branch')" + ), + base: z + .string() + .default("main") + .describe("Name of the branch to merge changes into (default: 'main')"), + body: z + .string() + .optional() + .describe("Pull request description (raw markdown, optional)"), + draft: z + .boolean() + .default(false) + .describe("Whether to create the pull request as a draft"), + maintainer_can_modify: z + .boolean() + .optional() + .describe( + "Whether maintainers can modify the pull request (only applies to cross-repo PRs)" + ), +}); + +// ─── tool ──────────────────────────────────────────────────────────────────── + +export function register(server: McpServer): void { + server.tool( + "create_pull_request", + "Create a new GitHub Pull Request for a configured project.", + CreatePullRequestInput.shape, + async (input) => { + logger.info("tool called: create_pull_request", { + project: input.project, + title: input.title, + head: input.head, + base: input.base, + draft: input.draft, + }); + + try { + const project = getProject(input.project); + const octokit = getOctokit(project); + + logger.info("create_pull_request: creating GitHub pull request", { + owner: project.owner, + repo: project.repo, + title: input.title, + head: input.head, + base: input.base, + draft: input.draft, + }); + + const response = await octokit.pulls.create({ + owner: project.owner, + repo: project.repo, + title: input.title, + head: input.head, + base: input.base, + ...(input.body !== undefined ? { body: input.body } : {}), + draft: input.draft, + ...(input.maintainer_can_modify !== undefined + ? { maintainer_can_modify: input.maintainer_can_modify } + : {}), + }); + + const pr = response.data; + + logger.info("create_pull_request done", { + number: pr.number, + url: pr.html_url, + draft: pr.draft, + }); + + return { + content: [ + { + type: "text", + text: [ + `## Pull Request Created`, + ``, + `**#${pr.number}**: [${pr.title}](${pr.html_url})`, + `**Repository**: ${project.owner}/${project.repo}`, + `**Head → Base**: \`${input.head}\` → \`${input.base}\``, + `**Draft**: ${pr.draft ? "yes" : "no"}`, + `**URL**: ${pr.html_url}`, + ].join("\n"), + }, + ], + }; + } catch (err) { + logger.error("create_pull_request failed", { error: String(err) }); + throw err; + } + } + ); +} diff --git a/tests/src/router.test.ts b/tests/src/router.test.ts index a5690f6..2c089bd 100644 --- a/tests/src/router.test.ts +++ b/tests/src/router.test.ts @@ -11,6 +11,7 @@ vi.mock("../../src/tools/fetchIssue.js", () => ({ register: mockRegister })); vi.mock("../../src/tools/listIssues.js", () => ({ register: mockRegister })); vi.mock("../../src/tools/addComment.js", () => ({ register: mockRegister })); vi.mock("../../src/tools/updateIssue.js", () => ({ register: mockRegister })); +vi.mock("../../src/tools/createPullRequest.js", () => ({ register: mockRegister })); // ─── imports ───────────────────────────────────────────────────────────────── @@ -23,10 +24,10 @@ describe("registerAllTools", () => { vi.clearAllMocks(); }); - it("registers all 6 tools", () => { + it("registers all 7 tools", () => { const server = {} as McpServer; registerAllTools(server); - expect(mockRegister).toHaveBeenCalledTimes(6); + expect(mockRegister).toHaveBeenCalledTimes(7); }); it("passes the server instance to each register call", () => { diff --git a/tests/src/tools/createPullRequest.test.ts b/tests/src/tools/createPullRequest.test.ts new file mode 100644 index 0000000..f5e804b --- /dev/null +++ b/tests/src/tools/createPullRequest.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +// ─── mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("../../../src/logger.js", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + logFile: "/dev/null", + }, +})); + +const { mockCreate } = vi.hoisted(() => ({ + mockCreate: vi.fn(), +})); + +vi.mock("../../../src/config.js", () => ({ + getProject: vi.fn().mockReturnValue({ + owner: "myorg", + repo: "myrepo", + tokenEnv: "GITHUB_TOKEN", + }), + getToken: vi.fn().mockReturnValue("ghp_testtoken"), + getOctokit: vi.fn().mockReturnValue({ + pulls: { create: mockCreate }, + }), +})); + +// ─── imports ───────────────────────────────────────────────────────────────── + +import { register } from "../../../src/tools/createPullRequest.js"; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +type Handler = (input: Record) => Promise<{ + content: Array<{ type: string; text: string }>; +}>; + +function createMockServer() { + const handlers: Record = {}; + const server = { + tool: vi.fn( + (name: string, _desc: string, _schema: unknown, handler: Handler) => { + handlers[name] = handler; + } + ), + } as unknown as McpServer; + return { server, handlers }; +} + +const SAMPLE_INPUT = { + project: "api", + title: "Add new feature", + head: "feature/my-feature", + base: "main", + draft: false, +}; + +// ─── register ──────────────────────────────────────────────────────────────── + +describe("create_pull_request registration", () => { + it("registers the tool with the correct name", () => { + const { server, handlers } = createMockServer(); + register(server); + expect(handlers["create_pull_request"]).toBeDefined(); + }); +}); + +// ─── handler: happy path ────────────────────────────────────────────────────── + +describe("create_pull_request handler — happy path", () => { + let handlers: Record; + + beforeEach(() => { + const mock = createMockServer(); + register(mock.server); + handlers = mock.handlers; + + mockCreate.mockResolvedValue({ + data: { + number: 7, + title: "Add new feature", + html_url: "https://github.com/myorg/myrepo/pull/7", + draft: false, + }, + }); + }); + + it("calls octokit.pulls.create with owner and repo from project config", async () => { + await handlers["create_pull_request"]({ ...SAMPLE_INPUT }); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ owner: "myorg", repo: "myrepo" }) + ); + }); + + it("passes title, head, and base to octokit", async () => { + await handlers["create_pull_request"]({ ...SAMPLE_INPUT }); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Add new feature", + head: "feature/my-feature", + base: "main", + }) + ); + }); + + it("passes draft: false when not set to draft", async () => { + await handlers["create_pull_request"]({ ...SAMPLE_INPUT, draft: false }); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ draft: false }) + ); + }); + + it("returns PR number in response text", async () => { + const result = await handlers["create_pull_request"]({ ...SAMPLE_INPUT }); + expect(result.content[0].text).toContain("#7"); + }); + + it("returns PR URL in response text", async () => { + const result = await handlers["create_pull_request"]({ ...SAMPLE_INPUT }); + expect(result.content[0].text).toContain( + "https://github.com/myorg/myrepo/pull/7" + ); + }); + + it("includes head → base in response text", async () => { + const result = await handlers["create_pull_request"]({ ...SAMPLE_INPUT }); + expect(result.content[0].text).toContain("feature/my-feature"); + expect(result.content[0].text).toContain("main"); + }); +}); + +// ─── handler: draft flag ────────────────────────────────────────────────────── + +describe("create_pull_request handler — draft flag", () => { + let handlers: Record; + + beforeEach(() => { + const mock = createMockServer(); + register(mock.server); + handlers = mock.handlers; + + mockCreate.mockResolvedValue({ + data: { + number: 8, + title: "Draft PR", + html_url: "https://github.com/myorg/myrepo/pull/8", + draft: true, + }, + }); + }); + + it("passes draft: true to octokit when draft is set", async () => { + await handlers["create_pull_request"]({ ...SAMPLE_INPUT, draft: true }); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ draft: true }) + ); + }); + + it("includes draft status in response text", async () => { + const result = await handlers["create_pull_request"]({ + ...SAMPLE_INPUT, + draft: true, + }); + expect(result.content[0].text).toContain("yes"); + }); +}); + +// ─── handler: optional fields ───────────────────────────────────────────────── + +describe("create_pull_request handler — optional fields", () => { + let handlers: Record; + + beforeEach(() => { + const mock = createMockServer(); + register(mock.server); + handlers = mock.handlers; + + mockCreate.mockResolvedValue({ + data: { + number: 9, + title: "PR with body", + html_url: "https://github.com/myorg/myrepo/pull/9", + draft: false, + }, + }); + }); + + it("passes body to octokit when provided", async () => { + await handlers["create_pull_request"]({ + ...SAMPLE_INPUT, + body: "Closes #42", + }); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ body: "Closes #42" }) + ); + }); + + it("omits body from octokit call when not provided", async () => { + await handlers["create_pull_request"]({ ...SAMPLE_INPUT }); + const call = mockCreate.mock.calls[0][0]; + expect(call).not.toHaveProperty("body"); + }); + + it("passes maintainer_can_modify when provided", async () => { + await handlers["create_pull_request"]({ + ...SAMPLE_INPUT, + maintainer_can_modify: true, + }); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ maintainer_can_modify: true }) + ); + }); + + it("omits maintainer_can_modify when not provided", async () => { + await handlers["create_pull_request"]({ ...SAMPLE_INPUT }); + const call = mockCreate.mock.calls[0][0]; + expect(call).not.toHaveProperty("maintainer_can_modify"); + }); +}); + +// ─── handler: error scenarios ───────────────────────────────────────────────── + +describe("create_pull_request handler — error handling", () => { + let handlers: Record; + + beforeEach(() => { + const mock = createMockServer(); + register(mock.server); + handlers = mock.handlers; + }); + + it("rethrows octokit error without swallowing it", async () => { + const apiError = new Error("Unprocessable Entity: branches must differ"); + mockCreate.mockRejectedValue(apiError); + + await expect( + handlers["create_pull_request"]({ ...SAMPLE_INPUT }) + ).rejects.toThrow("Unprocessable Entity: branches must differ"); + }); + + it("logs error via logger.error before rethrowing", async () => { + const { logger } = await import("../../../src/logger.js"); + const apiError = new Error("422 branches must differ"); + mockCreate.mockRejectedValue(apiError); + + await expect( + handlers["create_pull_request"]({ ...SAMPLE_INPUT }) + ).rejects.toThrow(); + + expect(logger.error).toHaveBeenCalledWith( + "create_pull_request failed", + expect.objectContaining({ error: expect.stringContaining("422") }) + ); + }); +}); From 5a5a275b779e50dfed3e40c540b8c39aa495fddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=9B=D1=8F=D1=89=D1=83=D0=BA?= <40496434+prog-time@users.noreply.github.com> Date: Sat, 18 Apr 2026 16:06:10 +0300 Subject: [PATCH 2/2] issues-1|mock fs in config tests to remove dependency on projects.yaml ------------------------------ Files: Changed: tests/src/config.test.ts --- tests/src/config.test.ts | 41 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/tests/src/config.test.ts b/tests/src/config.test.ts index 1176876..a872a40 100644 --- a/tests/src/config.test.ts +++ b/tests/src/config.test.ts @@ -1,7 +1,44 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; + +// ─── mocks ──────────────────────────────────────────────────────────────────── + +// Stub `fs` so loadConfig() in src/config.ts reads a deterministic projects.yaml +// regardless of what exists on disk. Other fs calls (e.g. dotenv reading .env) +// pass through to the real implementation. +vi.mock("fs", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actual = (await importOriginal()) as any; + + const fakeYaml = [ + "projects:", + " talksy:", + " owner: prog-time", + " repo: talksy", + " tokenEnv: GITHUB_TOKEN", + "", + ].join("\n"); + + const isProjectsYaml = (p: unknown): p is string => + typeof p === "string" && p.endsWith("projects.yaml"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const patch = (orig: any) => ({ + ...orig, + existsSync: (p: unknown) => + isProjectsYaml(p) ? true : orig.existsSync(p), + readFileSync: (p: unknown, opts?: unknown) => + isProjectsYaml(p) ? fakeYaml : orig.readFileSync(p, opts), + }); + + const patched = patch(actual); + return { ...patched, default: patch(actual.default ?? actual) }; +}); + +// ─── imports ───────────────────────────────────────────────────────────────── + import { getProject, getToken } from "../../src/config.js"; -// These tests use the real projects.yaml (projects: talksy) +// ─── tests ─────────────────────────────────────────────────────────────────── describe("getProject", () => { it("returns config for an existing project", () => {