mirror of
https://github.com/shadoll/redmine-mcp.git
synced 2026-08-28 03:27:56 +00:00
Enhance issue management tools: add relations and watchers functionality, update README
This commit is contained in:
@@ -6,17 +6,41 @@ Exposes Redmine REST API as tools for Claude Code via MCP (Model Context Protoco
|
|||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `create_issue` | Create a new issue in a project |
|
**Issues**
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `create_issue` | Create a new issue (supports subtask, watchers, relations) |
|
||||||
| `get_issue` | Get issue details by ID |
|
| `get_issue` | Get issue details by ID |
|
||||||
| `list_issues` | Query issues with filters (project, status, assignee) |
|
| `list_issues` | Query issues with filters (project, status, assignee) |
|
||||||
| `update_issue` | Update title, description, priority, due date |
|
| `update_issue` | Update title, description, priority, due date, subtask, watchers, relations |
|
||||||
| `update_issue_status` | Change issue status |
|
| `update_issue_status` | Change issue status |
|
||||||
| `update_issue_progress` | Set % done (0–100) |
|
| `update_issue_progress` | Set % done (0–100) |
|
||||||
|
|
||||||
|
**Comments & members**
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
| `get_issue_comments` | Get all comments and field changes |
|
| `get_issue_comments` | Get all comments and field changes |
|
||||||
| `add_issue_comment` | Post a new comment |
|
| `add_issue_comment` | Post a new comment |
|
||||||
| `assign_issue` | Assign issue to a user |
|
| `assign_issue` | Assign issue to a user |
|
||||||
| `get_project_members` | List project members with user IDs |
|
| `get_project_members` | List project members with user IDs |
|
||||||
| `get_issue_statuses` | List all available statuses (use to find status IDs) |
|
| `get_issue_statuses` | List all available statuses (use to find status IDs) |
|
||||||
|
|
||||||
|
**Relations & watchers**
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `get_issue_relations` | List all relations for an issue |
|
||||||
|
| `add_issue_relation` | Add a relation between two issues |
|
||||||
|
| `remove_issue_relation` | Remove a relation by relation ID |
|
||||||
|
| `add_watcher` | Add a watcher to an issue |
|
||||||
|
| `remove_watcher` | Remove a watcher from an issue |
|
||||||
|
|
||||||
|
**Search**
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
| `search` | Search by keyword across issues, wiki, news, documents, and more |
|
| `search` | Search by keyword across issues, wiki, news, documents, and more |
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|||||||
@@ -32,3 +32,10 @@ def _post(path: str, body: dict) -> dict:
|
|||||||
resp = client.post(url, headers=_headers(), content=json.dumps(body))
|
resp = client.post(url, headers=_headers(), content=json.dumps(body))
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _delete(path: str) -> None:
|
||||||
|
url = f"{REDMINE_URL}{path}"
|
||||||
|
with httpx.Client(timeout=15) as client:
|
||||||
|
resp = client.delete(url, headers=_headers())
|
||||||
|
resp.raise_for_status()
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from . import issues, comments, members, statuses, search
|
from . import issues, comments, members, statuses, search, relations
|
||||||
|
|
||||||
__all__ = ["issues", "comments", "members", "statuses", "search"]
|
__all__ = ["issues", "comments", "members", "statuses", "search", "relations"]
|
||||||
|
|||||||
+68
-16
@@ -1,6 +1,17 @@
|
|||||||
import json
|
import json
|
||||||
from ..mcp import mcp
|
from ..mcp import mcp
|
||||||
from ..client import _get, _put, _post
|
from ..client import _get, _put, _post, _delete
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_relations(issue_id: int, relations: list[dict]) -> None:
|
||||||
|
"""Create issue relations. Each item: {"issue_id": int, "relation_type": str}."""
|
||||||
|
for rel in relations:
|
||||||
|
_post(f"/issues/{issue_id}/relations.json", {
|
||||||
|
"relation": {
|
||||||
|
"issue_to_id": rel["issue_id"],
|
||||||
|
"relation_type": rel.get("relation_type", "relates"),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -11,17 +22,26 @@ def create_issue(
|
|||||||
assigned_to_id: int = 0,
|
assigned_to_id: int = 0,
|
||||||
priority_id: int = 0,
|
priority_id: int = 0,
|
||||||
due_date: str = "",
|
due_date: str = "",
|
||||||
|
parent_issue_id: int = 0,
|
||||||
|
watcher_user_ids: list[int] | None = None,
|
||||||
|
relations: list[dict] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Create a new Redmine issue.
|
Create a new Redmine issue.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_id: Project ID or slug
|
project_id: Project ID or slug
|
||||||
subject: Issue title
|
subject: Issue title
|
||||||
description: Issue description
|
description: Issue description
|
||||||
assigned_to_id: User ID to assign (use get_project_members to find IDs)
|
assigned_to_id: User ID to assign (use get_project_members to find IDs)
|
||||||
priority_id: Priority ID (leave 0 for default)
|
priority_id: Priority ID (leave 0 for default)
|
||||||
due_date: Due date in YYYY-MM-DD format
|
due_date: Due date in YYYY-MM-DD format
|
||||||
|
parent_issue_id: Parent issue ID (makes this a subtask)
|
||||||
|
watcher_user_ids: List of user IDs to add as watchers
|
||||||
|
relations: List of related issues, e.g.
|
||||||
|
[{"issue_id": 42, "relation_type": "relates"}]
|
||||||
|
Types: relates, duplicates, duplicated,
|
||||||
|
blocks, blocked, precedes, follows
|
||||||
"""
|
"""
|
||||||
payload: dict = {"project_id": project_id, "subject": subject}
|
payload: dict = {"project_id": project_id, "subject": subject}
|
||||||
if description:
|
if description:
|
||||||
@@ -32,9 +52,17 @@ def create_issue(
|
|||||||
payload["priority_id"] = priority_id
|
payload["priority_id"] = priority_id
|
||||||
if due_date:
|
if due_date:
|
||||||
payload["due_date"] = due_date
|
payload["due_date"] = due_date
|
||||||
|
if parent_issue_id:
|
||||||
|
payload["parent_issue_id"] = parent_issue_id
|
||||||
|
if watcher_user_ids:
|
||||||
|
payload["watcher_user_ids"] = watcher_user_ids
|
||||||
|
|
||||||
data = _post("/issues.json", {"issue": payload})
|
data = _post("/issues.json", {"issue": payload})
|
||||||
issue = data["issue"]
|
issue = data["issue"]
|
||||||
|
|
||||||
|
if relations:
|
||||||
|
_apply_relations(issue["id"], relations)
|
||||||
|
|
||||||
return json.dumps({"id": issue["id"], "subject": issue["subject"]}, ensure_ascii=False, indent=2)
|
return json.dumps({"id": issue["id"], "subject": issue["subject"]}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
@@ -102,17 +130,26 @@ def update_issue(
|
|||||||
priority_id: int = 0,
|
priority_id: int = 0,
|
||||||
due_date: str = "",
|
due_date: str = "",
|
||||||
comment: str = "",
|
comment: str = "",
|
||||||
|
parent_issue_id: int = 0,
|
||||||
|
watcher_user_ids: list[int] | None = None,
|
||||||
|
relations: list[dict] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Update general fields of a Redmine issue.
|
Update general fields of a Redmine issue.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
issue_id: Redmine issue ID
|
issue_id: Redmine issue ID
|
||||||
subject: New title (leave empty to keep current)
|
subject: New title (leave empty to keep current)
|
||||||
description: New description (leave empty to keep current)
|
description: New description (leave empty to keep current)
|
||||||
priority_id: Priority ID (leave 0 to keep current)
|
priority_id: Priority ID (leave 0 to keep current)
|
||||||
due_date: Due date in YYYY-MM-DD format (leave empty to keep current)
|
due_date: Due date in YYYY-MM-DD format (leave empty to keep current)
|
||||||
comment: Optional journal note
|
comment: Optional journal note
|
||||||
|
parent_issue_id: Parent issue ID (makes this a subtask; 0 = no change)
|
||||||
|
watcher_user_ids: List of user IDs to add as watchers
|
||||||
|
relations: List of relations to add, e.g.
|
||||||
|
[{"issue_id": 42, "relation_type": "blocks"}]
|
||||||
|
Types: relates, duplicates, duplicated,
|
||||||
|
blocks, blocked, precedes, follows
|
||||||
"""
|
"""
|
||||||
payload: dict = {}
|
payload: dict = {}
|
||||||
if subject:
|
if subject:
|
||||||
@@ -125,12 +162,27 @@ def update_issue(
|
|||||||
payload["due_date"] = due_date
|
payload["due_date"] = due_date
|
||||||
if comment:
|
if comment:
|
||||||
payload["notes"] = comment
|
payload["notes"] = comment
|
||||||
|
if parent_issue_id:
|
||||||
|
payload["parent_issue_id"] = parent_issue_id
|
||||||
|
|
||||||
if not payload:
|
if not payload and not watcher_user_ids and not relations:
|
||||||
return "Nothing to update — all fields are empty."
|
return "Nothing to update — all fields are empty."
|
||||||
|
|
||||||
_put(f"/issues/{issue_id}.json", {"issue": payload})
|
if payload:
|
||||||
return f"Issue #{issue_id} updated: {list(payload.keys())}."
|
_put(f"/issues/{issue_id}.json", {"issue": payload})
|
||||||
|
|
||||||
|
for user_id in (watcher_user_ids or []):
|
||||||
|
_post(f"/issues/{issue_id}/watchers.json", {"user_id": user_id})
|
||||||
|
|
||||||
|
if relations:
|
||||||
|
_apply_relations(issue_id, relations)
|
||||||
|
|
||||||
|
updated = list(payload.keys())
|
||||||
|
if watcher_user_ids:
|
||||||
|
updated.append("watchers")
|
||||||
|
if relations:
|
||||||
|
updated.append("relations")
|
||||||
|
return f"Issue #{issue_id} updated: {updated}."
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import json
|
||||||
|
from ..mcp import mcp
|
||||||
|
from ..client import _get, _post, _delete
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_issue_relations(issue_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Get all relations for a Redmine issue.
|
||||||
|
Returns relation IDs needed for remove_issue_relation.
|
||||||
|
"""
|
||||||
|
data = _get(f"/issues/{issue_id}/relations.json")
|
||||||
|
return json.dumps(data.get("relations", []), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def add_issue_relation(
|
||||||
|
issue_id: int,
|
||||||
|
related_issue_id: int,
|
||||||
|
relation_type: str = "relates",
|
||||||
|
delay: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Add a relation between two Redmine issues.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issue_id: Source issue ID
|
||||||
|
related_issue_id: Target issue ID
|
||||||
|
relation_type: Type of relation:
|
||||||
|
- relates — generic relation
|
||||||
|
- duplicates — this issue duplicates the target
|
||||||
|
- duplicated — this issue is duplicated by the target
|
||||||
|
- blocks — this issue blocks the target
|
||||||
|
- blocked — this issue is blocked by the target
|
||||||
|
- precedes — this issue must precede the target
|
||||||
|
- follows — this issue must follow the target
|
||||||
|
- copied_to — this issue was copied to the target
|
||||||
|
- copied_from — this issue was copied from the target
|
||||||
|
delay: Delay in days (used with precedes/follows)
|
||||||
|
"""
|
||||||
|
body: dict = {"issue_to_id": related_issue_id, "relation_type": relation_type}
|
||||||
|
if delay:
|
||||||
|
body["delay"] = delay
|
||||||
|
data = _post(f"/issues/{issue_id}/relations.json", {"relation": body})
|
||||||
|
return json.dumps(data.get("relation", {}), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def remove_issue_relation(relation_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Remove a relation between issues by relation ID.
|
||||||
|
Use get_issue_relations to find the relation ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
relation_id: Relation ID (from get_issue_relations)
|
||||||
|
"""
|
||||||
|
_delete(f"/relations/{relation_id}.json")
|
||||||
|
return f"Relation #{relation_id} removed."
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def add_watcher(issue_id: int, user_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Add a watcher to a Redmine issue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issue_id: Redmine issue ID
|
||||||
|
user_id: User ID to add as watcher (use get_project_members to find IDs)
|
||||||
|
"""
|
||||||
|
_post(f"/issues/{issue_id}/watchers.json", {"user_id": user_id})
|
||||||
|
return f"User #{user_id} added as watcher to issue #{issue_id}."
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def remove_watcher(issue_id: int, user_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Remove a watcher from a Redmine issue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issue_id: Redmine issue ID
|
||||||
|
user_id: User ID to remove from watchers
|
||||||
|
"""
|
||||||
|
_delete(f"/issues/{issue_id}/watchers/{user_id}.json")
|
||||||
|
return f"User #{user_id} removed from watchers of issue #{issue_id}."
|
||||||
Reference in New Issue
Block a user