Enhance issue management tools: add relations and watchers functionality, update README

This commit is contained in:
sha
2026-03-20 14:17:14 +02:00
parent 1bfb98b7c0
commit 42cf48a9a4
5 changed files with 187 additions and 20 deletions
+7
View File
@@ -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()
+2 -2
View File
@@ -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
View File
@@ -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()
+84
View File
@@ -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}."