diff --git a/app/entities/__init__.py b/app/entities/__init__.py index 5e3a6dc..2e45b79 100644 --- a/app/entities/__init__.py +++ b/app/entities/__init__.py @@ -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"] diff --git a/app/entities/comments.py b/app/entities/comments.py index b65f813..a130d78 100644 --- a/app/entities/comments.py +++ b/app/entities/comments.py @@ -24,6 +24,91 @@ def get_issue_comments(issue_id: int) -> str: 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() def add_issue_comment(issue_id: int, comment: str) -> str: """ diff --git a/app/entities/members.py b/app/entities/members.py index 58652b0..a424b34 100644 --- a/app/entities/members.py +++ b/app/entities/members.py @@ -39,3 +39,68 @@ def assign_issue(issue_id: int, assigned_to_id: int, comment: str = "") -> str: body["issue"]["notes"] = comment _put(f"/issues/{issue_id}.json", body) 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) diff --git a/app/entities/users.py b/app/entities/users.py new file mode 100644 index 0000000..d97b059 --- /dev/null +++ b/app/entities/users.py @@ -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)