refactor, add modularity

This commit is contained in:
sha
2026-03-20 14:09:28 +02:00
parent b56cc63c84
commit 3a701a5280
13 changed files with 354 additions and 273 deletions
+3
View File
@@ -0,0 +1,3 @@
from . import issues, comments, members, statuses, search
__all__ = ["issues", "comments", "members", "statuses", "search"]
+37
View File
@@ -0,0 +1,37 @@
import json
from ..mcp import mcp
from ..client import _get, _put
@mcp.tool()
def get_issue_comments(issue_id: int) -> str:
"""
Get all comments (journal notes) for a Redmine issue.
Returns list of comments with author, date, and text.
"""
data = _get(f"/issues/{issue_id}.json", params={"include": "journals"})
journals = data["issue"].get("journals", [])
comments = [
{
"id": j["id"],
"author": j["user"]["name"],
"created_on": j["created_on"],
"notes": j.get("notes", ""),
"details": j.get("details", []),
}
for j in journals
]
return json.dumps(comments, ensure_ascii=False, indent=2)
@mcp.tool()
def add_issue_comment(issue_id: int, comment: str) -> str:
"""
Add a comment (journal note) to a Redmine issue.
Args:
issue_id: Redmine issue ID
comment: Text of the comment to add
"""
_put(f"/issues/{issue_id}.json", {"issue": {"notes": comment}})
return f"Comment added to issue #{issue_id}."
+134
View File
@@ -0,0 +1,134 @@
import json
from ..mcp import mcp
from ..client import _get, _put
@mcp.tool()
def get_issue(issue_id: int) -> str:
"""
Get a Redmine issue by ID.
Returns full issue details: title, description, status, priority,
assignee, progress, dates, custom fields.
"""
data = _get(f"/issues/{issue_id}.json")
return json.dumps(data["issue"], ensure_ascii=False, indent=2)
@mcp.tool()
def list_issues(
project_id: str = "",
status_id: str = "open",
assigned_to_id: str = "",
limit: int = 25,
offset: int = 0,
) -> str:
"""
List Redmine issues with optional filters.
Args:
project_id: Filter by project ID or slug (empty = all projects)
status_id: "open", "closed", "*" (all), or a numeric status ID
assigned_to_id: Numeric user ID, or "me" for the API key owner
limit: Max results (1100, default 25)
offset: Pagination offset
"""
params: dict = {
"status_id": status_id,
"limit": min(limit, 100),
"offset": offset,
}
if assigned_to_id:
params["assigned_to_id"] = assigned_to_id
path = f"/projects/{project_id}/issues.json" if project_id else "/issues.json"
data = _get(path, params=params)
issues = [
{
"id": i["id"],
"subject": i["subject"],
"status": i["status"]["name"],
"priority": i["priority"]["name"],
"assigned_to": i.get("assigned_to", {}).get("name", ""),
"done_ratio": i.get("done_ratio", 0),
"updated_on": i.get("updated_on", ""),
}
for i in data.get("issues", [])
]
total = data.get("total_count", len(issues))
return json.dumps({"total": total, "issues": issues}, ensure_ascii=False, indent=2)
@mcp.tool()
def update_issue(
issue_id: int,
subject: str = "",
description: str = "",
priority_id: int = 0,
due_date: str = "",
comment: str = "",
) -> 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
"""
payload: dict = {}
if subject:
payload["subject"] = subject
if description:
payload["description"] = description
if priority_id:
payload["priority_id"] = priority_id
if due_date:
payload["due_date"] = due_date
if comment:
payload["notes"] = comment
if not payload:
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())}."
@mcp.tool()
def update_issue_status(issue_id: int, status_id: int, comment: str = "") -> str:
"""
Change the status of a Redmine issue.
Args:
issue_id: Redmine issue ID
status_id: Target status ID (use get_issue_statuses to find IDs)
comment: Optional comment to add with the status change
"""
body: dict = {"issue": {"status_id": status_id}}
if comment:
body["issue"]["notes"] = comment
_put(f"/issues/{issue_id}.json", body)
return f"Issue #{issue_id} status updated to status_id={status_id}."
@mcp.tool()
def update_issue_progress(issue_id: int, done_ratio: int, comment: str = "") -> str:
"""
Update the % done (progress) of a Redmine issue.
Args:
issue_id: Redmine issue ID
done_ratio: Progress value 0100 (percent done)
comment: Optional comment to add with the update
"""
if not 0 <= done_ratio <= 100:
return "Error: done_ratio must be between 0 and 100."
body: dict = {"issue": {"done_ratio": done_ratio}}
if comment:
body["issue"]["notes"] = comment
_put(f"/issues/{issue_id}.json", body)
return f"Issue #{issue_id} progress updated to {done_ratio}%."
+41
View File
@@ -0,0 +1,41 @@
import json
from ..mcp import mcp
from ..client import _get, _put
@mcp.tool()
def get_project_members(project_id: str) -> str:
"""
Get members of a Redmine project (useful for finding user IDs for assignment).
Args:
project_id: Project ID or identifier (slug)
"""
data = _get(f"/projects/{project_id}/memberships.json")
members = [
{
"id": m.get("user", {}).get("id"),
"name": m.get("user", {}).get("name"),
"roles": [r["name"] for r in m.get("roles", [])],
}
for m in data.get("memberships", [])
if "user" in m
]
return json.dumps(members, ensure_ascii=False, indent=2)
@mcp.tool()
def assign_issue(issue_id: int, assigned_to_id: int, comment: str = "") -> str:
"""
Assign a Redmine issue to a user.
Args:
issue_id: Redmine issue ID
assigned_to_id: User ID to assign to (use get_project_members to find IDs)
comment: Optional comment to add with the assignment
"""
body: dict = {"issue": {"assigned_to_id": assigned_to_id}}
if comment:
body["issue"]["notes"] = comment
_put(f"/issues/{issue_id}.json", body)
return f"Issue #{issue_id} assigned to user_id={assigned_to_id}."
+67
View File
@@ -0,0 +1,67 @@
import json
from ..mcp import mcp
from ..client import _get
@mcp.tool()
def search(
query: str,
project_id: str = "",
scope: str = "all",
issues: bool = True,
wiki_pages: bool = True,
news: bool = False,
documents: bool = False,
changesets: bool = False,
messages: bool = False,
projects: bool = False,
titles_only: bool = False,
open_issues: bool = False,
attachments: bool = False,
limit: int = 25,
offset: int = 0,
) -> str:
"""
Search Redmine by keyword across multiple resource types.
Args:
query: Search keyword(s)
project_id: Limit search to a specific project (ID or slug); empty = global
scope: "all" (all projects) or "my_projects" (only projects I'm member of)
issues: Include issues in results (default True)
wiki_pages: Include wiki pages in results (default True)
news: Include news in results
documents: Include documents in results
changesets: Include changesets/commits in results
messages: Include forum messages in results
projects: Include projects in results
titles_only: Search in titles/subjects only (skip descriptions/content)
open_issues: Return open issues only
attachments: Search inside attachment names/content
limit: Max results (1100, default 25)
offset: Pagination offset
"""
params: dict = {
"q": query,
"scope": scope,
"all_words": 1,
"titles_only": int(titles_only),
"open_issues": int(open_issues),
"attachments": int(attachments),
"issues": int(issues),
"wiki_pages": int(wiki_pages),
"news": int(news),
"documents": int(documents),
"changesets": int(changesets),
"messages": int(messages),
"projects": int(projects),
"limit": min(limit, 100),
"offset": offset,
}
if project_id:
params["project_id"] = project_id
data = _get("/search.json", params=params)
results = data.get("results", [])
total = data.get("total_count", len(results))
return json.dumps({"total": total, "results": results}, ensure_ascii=False, indent=2)
+13
View File
@@ -0,0 +1,13 @@
import json
from ..mcp import mcp
from ..client import _get
@mcp.tool()
def get_issue_statuses() -> str:
"""
Get all available issue statuses in Redmine.
Use this to find the correct status_id before calling update_issue_status.
"""
data = _get("/issue_statuses.json")
return json.dumps(data["issue_statuses"], ensure_ascii=False, indent=2)