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: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 24 additions & 2 deletions INSTRUCTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ claude mcp add -s user -- mcp-github-issues /путь/к/mcp-github-issues/mcp.s

## Доступные инструменты (Tools)

Сервер предоставляет 6 инструментов, которые AI-ассистент может вызывать в диалоге.
Сервер предоставляет 7 инструментов, которые AI-ассистент может вызывать в диалоге.

### list_projects

Expand Down Expand Up @@ -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"»

---

## Типичный рабочий процесс
Expand Down Expand Up @@ -319,7 +340,8 @@ mcp-github-issues/
│ ├── fetchIssue.ts
│ ├── publish.ts
│ ├── addComment.ts
│ └── updateIssue.ts
│ ├── updateIssue.ts
│ └── createPullRequest.ts
├── tests/ # Тесты (Vitest)
├── logs/ # Логи сервера
├── projects.yaml # Конфигурация проектов
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down Expand Up @@ -329,7 +347,8 @@ mcp-github-issues/
│ ├── fetchIssue.ts
│ ├── publish.ts
│ ├── addComment.ts
│ └── updateIssue.ts
│ ├── updateIssue.ts
│ └── createPullRequest.ts
├── tests/
│ └── src/
│ ├── config.test.ts
Expand Down
2 changes: 2 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -13,4 +14,5 @@ export function registerAllTools(server: McpServer): void {
listIssues.register(server);
addComment.register(server);
updateIssue.register(server);
createPullRequest.register(server);
}
108 changes: 108 additions & 0 deletions src/tools/createPullRequest.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
);
}
41 changes: 39 additions & 2 deletions tests/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
5 changes: 3 additions & 2 deletions tests/src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────────

Expand All @@ -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", () => {
Expand Down
Loading
Loading