diff --git a/README.md b/README.md index fa9d9e6..2e580a3 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,41 @@ Exposes Redmine REST API as tools for Claude Code via MCP (Model Context Protoco | 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 | | `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_progress` | Set % done (0–100) | + +**Comments & members** + +| Tool | Description | +|------|-------------| | `get_issue_comments` | Get all comments and field changes | | `add_issue_comment` | Post a new comment | | `assign_issue` | Assign issue to a user | | `get_project_members` | List project members with user 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 | ## Setup diff --git a/app/client.py b/app/client.py index 378132e..08d79d7 100644 --- a/app/client.py +++ b/app/client.py @@ -32,3 +32,10 @@ def _post(path: str, body: dict) -> dict: resp = client.post(url, headers=_headers(), content=json.dumps(body)) resp.raise_for_status() 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() diff --git a/app/entities/__init__.py b/app/entities/__init__.py index e3da70c..5e3a6dc 100644 --- a/app/entities/__init__.py +++ b/app/entities/__init__.py @@ -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"] diff --git a/app/entities/issues.py b/app/entities/issues.py index d9c2de2..e7fbaeb 100644 --- a/app/entities/issues.py +++ b/app/entities/issues.py @@ -1,6 +1,17 @@ import json 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() @@ -11,17 +22,26 @@ def create_issue( assigned_to_id: int = 0, priority_id: int = 0, due_date: str = "", + parent_issue_id: int = 0, + watcher_user_ids: list[int] | None = None, + relations: list[dict] | None = None, ) -> str: """ Create a new Redmine issue. Args: - project_id: Project ID or slug - subject: Issue title - description: Issue description - assigned_to_id: User ID to assign (use get_project_members to find IDs) - priority_id: Priority ID (leave 0 for default) - due_date: Due date in YYYY-MM-DD format + project_id: Project ID or slug + subject: Issue title + description: Issue description + assigned_to_id: User ID to assign (use get_project_members to find IDs) + priority_id: Priority ID (leave 0 for default) + 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} if description: @@ -32,9 +52,17 @@ def create_issue( payload["priority_id"] = priority_id if 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}) issue = data["issue"] + + if relations: + _apply_relations(issue["id"], relations) + 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, due_date: str = "", comment: str = "", + parent_issue_id: int = 0, + watcher_user_ids: list[int] | None = None, + relations: list[dict] | None = None, ) -> str: """ Update general fields of a Redmine issue. Args: - issue_id: Redmine issue ID - subject: New title (leave empty to keep current) - description: New description (leave empty 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) - comment: Optional journal note + issue_id: Redmine issue ID + subject: New title (leave empty to keep current) + description: New description (leave empty 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) + 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 = {} if subject: @@ -125,12 +162,27 @@ def update_issue( payload["due_date"] = due_date if 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." - _put(f"/issues/{issue_id}.json", {"issue": payload}) - return f"Issue #{issue_id} updated: {list(payload.keys())}." + if payload: + _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() diff --git a/app/entities/relations.py b/app/entities/relations.py new file mode 100644 index 0000000..ec998d0 --- /dev/null +++ b/app/entities/relations.py @@ -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}."