mirror of
https://github.com/shadoll/redmine-mcp.git
synced 2026-08-28 11:33:28 +00:00
feat: add user-related API endpoints and enhance issue participant retrieval
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
from . import issues, comments, members, statuses, search, relations
|
from . import issues, comments, members, statuses, search, relations, users
|
||||||
|
|
||||||
__all__ = ["issues", "comments", "members", "statuses", "search", "relations"]
|
__all__ = ["issues", "comments", "members", "statuses", "search", "relations", "users"]
|
||||||
|
|||||||
@@ -24,6 +24,91 @@ def get_issue_comments(issue_id: int) -> str:
|
|||||||
return json.dumps(comments, ensure_ascii=False, indent=2)
|
return json.dumps(comments, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_issue_history(issue_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Get the full change history of a Redmine issue with all field changes,
|
||||||
|
comments, and a deduplicated participant list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- issue: id, subject, author, created_on, current assignee,
|
||||||
|
current status, watchers
|
||||||
|
- history: chronological list of journal entries, each with:
|
||||||
|
user_id, user (name), created_on, notes (comment),
|
||||||
|
changes (list of field_name, old_value → new_value)
|
||||||
|
- participants: deduplicated list of every user who interacted with
|
||||||
|
the issue (author, assignee, watchers, commenters,
|
||||||
|
editors) with their roles
|
||||||
|
"""
|
||||||
|
data = _get(f"/issues/{issue_id}.json", params={"include": "journals,watchers"})
|
||||||
|
issue = data["issue"]
|
||||||
|
journals = issue.get("journals", [])
|
||||||
|
|
||||||
|
participants: dict[int, dict] = {}
|
||||||
|
|
||||||
|
def _add(uid: int, uname: str, role: str) -> None:
|
||||||
|
if uid not in participants:
|
||||||
|
participants[uid] = {"id": uid, "name": uname, "roles": []}
|
||||||
|
if role not in participants[uid]["roles"]:
|
||||||
|
participants[uid]["roles"].append(role)
|
||||||
|
|
||||||
|
author = issue.get("author", {})
|
||||||
|
if author.get("id"):
|
||||||
|
_add(author["id"], author["name"], "author")
|
||||||
|
|
||||||
|
assignee = issue.get("assigned_to", {})
|
||||||
|
if assignee.get("id"):
|
||||||
|
_add(assignee["id"], assignee["name"], "assignee")
|
||||||
|
|
||||||
|
for w in issue.get("watchers", []):
|
||||||
|
if w.get("id"):
|
||||||
|
_add(w["id"], w["name"], "watcher")
|
||||||
|
|
||||||
|
history = []
|
||||||
|
for j in journals:
|
||||||
|
user = j.get("user", {})
|
||||||
|
uid = user.get("id")
|
||||||
|
uname = user.get("name", "?")
|
||||||
|
if uid:
|
||||||
|
if j.get("notes"):
|
||||||
|
_add(uid, uname, "commenter")
|
||||||
|
if j.get("details"):
|
||||||
|
_add(uid, uname, "editor")
|
||||||
|
|
||||||
|
entry: dict = {
|
||||||
|
"id": j["id"],
|
||||||
|
"user_id": uid,
|
||||||
|
"user": uname,
|
||||||
|
"created_on": j["created_on"],
|
||||||
|
"notes": j.get("notes", ""),
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"field": d.get("name", ""),
|
||||||
|
"property": d.get("property", ""),
|
||||||
|
"old_value": d.get("old_value"),
|
||||||
|
"new_value": d.get("new_value"),
|
||||||
|
}
|
||||||
|
for d in j.get("details", [])
|
||||||
|
],
|
||||||
|
}
|
||||||
|
history.append(entry)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"issue": {
|
||||||
|
"id": issue["id"],
|
||||||
|
"subject": issue["subject"],
|
||||||
|
"author": author,
|
||||||
|
"created_on": issue.get("created_on", ""),
|
||||||
|
"assigned_to": assignee,
|
||||||
|
"status": issue.get("status", {}),
|
||||||
|
"watchers": issue.get("watchers", []),
|
||||||
|
},
|
||||||
|
"history": history,
|
||||||
|
"participants": list(participants.values()),
|
||||||
|
}
|
||||||
|
return json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def add_issue_comment(issue_id: int, comment: str) -> str:
|
def add_issue_comment(issue_id: int, comment: str) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -39,3 +39,68 @@ def assign_issue(issue_id: int, assigned_to_id: int, comment: str = "") -> str:
|
|||||||
body["issue"]["notes"] = comment
|
body["issue"]["notes"] = comment
|
||||||
_put(f"/issues/{issue_id}.json", body)
|
_put(f"/issues/{issue_id}.json", body)
|
||||||
return f"Issue #{issue_id} assigned to user_id={assigned_to_id}."
|
return f"Issue #{issue_id} assigned to user_id={assigned_to_id}."
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def unassign_issue(issue_id: int, comment: str = "") -> str:
|
||||||
|
"""
|
||||||
|
Remove the assignee from a Redmine issue (set to unassigned).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issue_id: Redmine issue ID
|
||||||
|
comment: Optional comment to add with the change
|
||||||
|
"""
|
||||||
|
body: dict = {"issue": {"assigned_to_id": ""}}
|
||||||
|
if comment:
|
||||||
|
body["issue"]["notes"] = comment
|
||||||
|
_put(f"/issues/{issue_id}.json", body)
|
||||||
|
return f"Issue #{issue_id} unassigned."
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_issue_participants(issue_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Get all users who have participated in a Redmine issue:
|
||||||
|
author, current assignee, watchers, commenters, and editors.
|
||||||
|
|
||||||
|
Useful for understanding who works with a task and who wrote comments.
|
||||||
|
Each participant includes their roles (author, assignee, watcher,
|
||||||
|
commenter, editor) based on actual actions recorded in the issue history.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
issue_id: Redmine issue ID
|
||||||
|
"""
|
||||||
|
data = _get(f"/issues/{issue_id}.json", params={"include": "journals,watchers"})
|
||||||
|
issue = data["issue"]
|
||||||
|
|
||||||
|
participants: dict[int, dict] = {}
|
||||||
|
|
||||||
|
def _add(uid: int, uname: str, role: str) -> None:
|
||||||
|
if uid not in participants:
|
||||||
|
participants[uid] = {"id": uid, "name": uname, "roles": []}
|
||||||
|
if role not in participants[uid]["roles"]:
|
||||||
|
participants[uid]["roles"].append(role)
|
||||||
|
|
||||||
|
author = issue.get("author", {})
|
||||||
|
if author.get("id"):
|
||||||
|
_add(author["id"], author["name"], "author")
|
||||||
|
|
||||||
|
assignee = issue.get("assigned_to", {})
|
||||||
|
if assignee.get("id"):
|
||||||
|
_add(assignee["id"], assignee["name"], "assignee")
|
||||||
|
|
||||||
|
for w in issue.get("watchers", []):
|
||||||
|
if w.get("id"):
|
||||||
|
_add(w["id"], w["name"], "watcher")
|
||||||
|
|
||||||
|
for j in issue.get("journals", []):
|
||||||
|
user = j.get("user", {})
|
||||||
|
uid = user.get("id")
|
||||||
|
uname = user.get("name", "?")
|
||||||
|
if uid:
|
||||||
|
if j.get("notes"):
|
||||||
|
_add(uid, uname, "commenter")
|
||||||
|
if j.get("details"):
|
||||||
|
_add(uid, uname, "editor")
|
||||||
|
|
||||||
|
return json.dumps(list(participants.values()), ensure_ascii=False, indent=2)
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import json
|
||||||
|
from ..mcp import mcp
|
||||||
|
from ..client import _get
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_current_user() -> str:
|
||||||
|
"""
|
||||||
|
Get information about the currently authenticated Redmine user (API key owner).
|
||||||
|
Returns user ID, login, name, email, created_on, last_login_on.
|
||||||
|
"""
|
||||||
|
data = _get("/users/current.json")
|
||||||
|
return json.dumps(data.get("user", {}), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_user(user_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Get a Redmine user by ID.
|
||||||
|
Returns user details: login, name, created_on, last_login_on, groups, memberships.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Redmine user ID
|
||||||
|
"""
|
||||||
|
data = _get(f"/users/{user_id}.json", params={"include": "groups,memberships"})
|
||||||
|
return json.dumps(data.get("user", {}), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_users(
|
||||||
|
status: int = 1,
|
||||||
|
name: str = "",
|
||||||
|
group_id: int = 0,
|
||||||
|
limit: int = 25,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
List Redmine users. Requires administrator privileges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: Filter by account status:
|
||||||
|
0 = anonymous, 1 = active (default), 2 = registered,
|
||||||
|
3 = locked, 4 = all
|
||||||
|
name: Filter by login, first/last name, or email (partial match)
|
||||||
|
group_id: Filter by group ID
|
||||||
|
limit: Max results (1–100, default 25)
|
||||||
|
offset: Pagination offset
|
||||||
|
"""
|
||||||
|
params: dict = {"status": status, "limit": min(limit, 100), "offset": offset}
|
||||||
|
if name:
|
||||||
|
params["name"] = name
|
||||||
|
if group_id:
|
||||||
|
params["group_id"] = group_id
|
||||||
|
|
||||||
|
data = _get("/users.json", params=params)
|
||||||
|
users = [
|
||||||
|
{
|
||||||
|
"id": u["id"],
|
||||||
|
"login": u.get("login", ""),
|
||||||
|
"name": f"{u.get('firstname', '')} {u.get('lastname', '')}".strip(),
|
||||||
|
"mail": u.get("mail", ""),
|
||||||
|
"created_on": u.get("created_on", ""),
|
||||||
|
"last_login_on": u.get("last_login_on", ""),
|
||||||
|
}
|
||||||
|
for u in data.get("users", [])
|
||||||
|
]
|
||||||
|
total = data.get("total_count", len(users))
|
||||||
|
return json.dumps({"total": total, "users": users}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def search_users(name: str) -> str:
|
||||||
|
"""
|
||||||
|
Search Redmine users by name, login, or email. Requires administrator privileges.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Partial name, login, or email to search for
|
||||||
|
"""
|
||||||
|
data = _get("/users.json", params={"name": name, "status": 1, "limit": 50})
|
||||||
|
users = [
|
||||||
|
{
|
||||||
|
"id": u["id"],
|
||||||
|
"login": u.get("login", ""),
|
||||||
|
"name": f"{u.get('firstname', '')} {u.get('lastname', '')}".strip(),
|
||||||
|
"mail": u.get("mail", ""),
|
||||||
|
}
|
||||||
|
for u in data.get("users", [])
|
||||||
|
]
|
||||||
|
return json.dumps(users, ensure_ascii=False, indent=2)
|
||||||
Reference in New Issue
Block a user