diff --git a/operation/operation.go b/operation/operation.go index 096adae..1171392 100644 --- a/operation/operation.go +++ b/operation/operation.go @@ -13,6 +13,7 @@ import ( "gitea.com/gitea/gitea-mcp/operation/issue" "gitea.com/gitea/gitea-mcp/operation/label" "gitea.com/gitea/gitea-mcp/operation/milestone" + "gitea.com/gitea/gitea-mcp/operation/timetracking" "gitea.com/gitea/gitea-mcp/operation/actions" "gitea.com/gitea/gitea-mcp/operation/pull" "gitea.com/gitea/gitea-mcp/operation/repo" @@ -60,6 +61,9 @@ func RegisterTool(s *server.MCPServer) { // Wiki Tool s.AddTools(wiki.Tool.Tools()...) + // Time Tracking Tool + s.AddTools(timetracking.Tool.Tools()...) + s.DeleteTools("") } diff --git a/operation/timetracking/timetracking.go b/operation/timetracking/timetracking.go new file mode 100644 index 0000000..04a5804 --- /dev/null +++ b/operation/timetracking/timetracking.go @@ -0,0 +1,376 @@ +// Package timetracking provides MCP tools for Gitea time tracking operations +package timetracking + +import ( + "context" + "fmt" + + gitea_sdk "code.gitea.io/sdk/gitea" + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/to" + "gitea.com/gitea/gitea-mcp/pkg/tool" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +var Tool = tool.New() + +const ( + // Stopwatch tools + StartStopwatchToolName = "start_stopwatch" + StopStopwatchToolName = "stop_stopwatch" + DeleteStopwatchToolName = "delete_stopwatch" + GetMyStopwatchesToolName = "get_my_stopwatches" + + // Tracked time tools + ListTrackedTimesToolName = "list_tracked_times" + AddTrackedTimeToolName = "add_tracked_time" + DeleteTrackedTimeToolName = "delete_tracked_time" + ListRepoTimesToolName = "list_repo_times" + GetMyTimesToolName = "get_my_times" +) + +var ( + // Stopwatch tools + StartStopwatchTool = mcp.NewTool( + StartStopwatchToolName, + mcp.WithDescription("Start a stopwatch on an issue to track time spent"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")), + ) + + StopStopwatchTool = mcp.NewTool( + StopStopwatchToolName, + mcp.WithDescription("Stop a running stopwatch on an issue and record the tracked time"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")), + ) + + DeleteStopwatchTool = mcp.NewTool( + DeleteStopwatchToolName, + mcp.WithDescription("Delete/cancel a running stopwatch without recording time"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")), + ) + + GetMyStopwatchesTool = mcp.NewTool( + GetMyStopwatchesToolName, + mcp.WithDescription("Get all currently running stopwatches for the authenticated user"), + ) + + // Tracked time tools + ListTrackedTimesTool = mcp.NewTool( + ListTrackedTimesToolName, + mcp.WithDescription("List tracked times for a specific issue"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")), + mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), + mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)), + ) + + AddTrackedTimeTool = mcp.NewTool( + AddTrackedTimeToolName, + mcp.WithDescription("Manually add tracked time to an issue"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")), + mcp.WithNumber("time", mcp.Required(), mcp.Description("time to add in seconds")), + ) + + DeleteTrackedTimeTool = mcp.NewTool( + DeleteTrackedTimeToolName, + mcp.WithDescription("Delete a tracked time entry from an issue"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("index", mcp.Required(), mcp.Description("issue index")), + mcp.WithNumber("id", mcp.Required(), mcp.Description("tracked time entry ID")), + ) + + ListRepoTimesTool = mcp.NewTool( + ListRepoTimesToolName, + mcp.WithDescription("List all tracked times for a repository"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)), + mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)), + ) + + GetMyTimesTool = mcp.NewTool( + GetMyTimesToolName, + mcp.WithDescription("Get all tracked times for the authenticated user"), + ) +) + +func init() { + // Stopwatch tools + Tool.RegisterWrite(server.ServerTool{Tool: StartStopwatchTool, Handler: StartStopwatchFn}) + Tool.RegisterWrite(server.ServerTool{Tool: StopStopwatchTool, Handler: StopStopwatchFn}) + Tool.RegisterWrite(server.ServerTool{Tool: DeleteStopwatchTool, Handler: DeleteStopwatchFn}) + Tool.RegisterRead(server.ServerTool{Tool: GetMyStopwatchesTool, Handler: GetMyStopwatchesFn}) + + // Tracked time tools + Tool.RegisterRead(server.ServerTool{Tool: ListTrackedTimesTool, Handler: ListTrackedTimesFn}) + Tool.RegisterWrite(server.ServerTool{Tool: AddTrackedTimeTool, Handler: AddTrackedTimeFn}) + Tool.RegisterWrite(server.ServerTool{Tool: DeleteTrackedTimeTool, Handler: DeleteTrackedTimeFn}) + Tool.RegisterRead(server.ServerTool{Tool: ListRepoTimesTool, Handler: ListRepoTimesFn}) + Tool.RegisterRead(server.ServerTool{Tool: GetMyTimesTool, Handler: GetMyTimesFn}) +} + +// Stopwatch handler functions + +func StartStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called StartStopwatchFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + index, ok := req.GetArguments()["index"].(float64) + if !ok { + return to.ErrorResult(fmt.Errorf("index is required")) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.StartIssueStopWatch(owner, repo, int64(index)) + if err != nil { + return to.ErrorResult(fmt.Errorf("start stopwatch on %s/%s#%d err: %v", owner, repo, int64(index), err)) + } + return to.TextResult(fmt.Sprintf("Stopwatch started on issue %s/%s#%d", owner, repo, int64(index))) +} + +func StopStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called StopStopwatchFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + index, ok := req.GetArguments()["index"].(float64) + if !ok { + return to.ErrorResult(fmt.Errorf("index is required")) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.StopIssueStopWatch(owner, repo, int64(index)) + if err != nil { + return to.ErrorResult(fmt.Errorf("stop stopwatch on %s/%s#%d err: %v", owner, repo, int64(index), err)) + } + return to.TextResult(fmt.Sprintf("Stopwatch stopped on issue %s/%s#%d - time recorded", owner, repo, int64(index))) +} + +func DeleteStopwatchFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called DeleteStopwatchFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + index, ok := req.GetArguments()["index"].(float64) + if !ok { + return to.ErrorResult(fmt.Errorf("index is required")) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.DeleteIssueStopwatch(owner, repo, int64(index)) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete stopwatch on %s/%s#%d err: %v", owner, repo, int64(index), err)) + } + return to.TextResult(fmt.Sprintf("Stopwatch deleted/cancelled on issue %s/%s#%d", owner, repo, int64(index))) +} + +func GetMyStopwatchesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetMyStopwatchesFn") + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + stopwatches, _, err := client.GetMyStopwatches() + if err != nil { + return to.ErrorResult(fmt.Errorf("get stopwatches err: %v", err)) + } + if len(stopwatches) == 0 { + return to.TextResult("No active stopwatches") + } + return to.TextResult(stopwatches) +} + +// Tracked time handler functions + +func ListTrackedTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListTrackedTimesFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + index, ok := req.GetArguments()["index"].(float64) + if !ok { + return to.ErrorResult(fmt.Errorf("index is required")) + } + page, ok := req.GetArguments()["page"].(float64) + if !ok { + page = 1 + } + pageSize, ok := req.GetArguments()["pageSize"].(float64) + if !ok { + pageSize = 100 + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + times, _, err := client.ListIssueTrackedTimes(owner, repo, int64(index), gitea_sdk.ListTrackedTimesOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: int(page), + PageSize: int(pageSize), + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list tracked times for %s/%s#%d err: %v", owner, repo, int64(index), err)) + } + if len(times) == 0 { + return to.TextResult(fmt.Sprintf("No tracked times for issue %s/%s#%d", owner, repo, int64(index))) + } + return to.TextResult(times) +} + +func AddTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called AddTrackedTimeFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + index, ok := req.GetArguments()["index"].(float64) + if !ok { + return to.ErrorResult(fmt.Errorf("index is required")) + } + + timeSeconds, ok := req.GetArguments()["time"].(float64) + if !ok { + return to.ErrorResult(fmt.Errorf("time is required")) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + trackedTime, _, err := client.AddTime(owner, repo, int64(index), gitea_sdk.AddTimeOption{ + Time: int64(timeSeconds), + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("add tracked time to %s/%s#%d err: %v", owner, repo, int64(index), err)) + } + return to.TextResult(trackedTime) +} + +func DeleteTrackedTimeFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called DeleteTrackedTimeFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + + index, ok := req.GetArguments()["index"].(float64) + if !ok { + return to.ErrorResult(fmt.Errorf("index is required")) + } + id, ok := req.GetArguments()["id"].(float64) + if !ok { + return to.ErrorResult(fmt.Errorf("id is required")) + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + _, err = client.DeleteTime(owner, repo, int64(index), int64(id)) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete tracked time %d from %s/%s#%d err: %v", int64(id), owner, repo, int64(index), err)) + } + return to.TextResult(fmt.Sprintf("Tracked time entry %d deleted from issue %s/%s#%d", int64(id), owner, repo, int64(index))) +} + +func ListRepoTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListRepoTimesFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + + page, ok := req.GetArguments()["page"].(float64) + if !ok { + page = 1 + } + pageSize, ok := req.GetArguments()["pageSize"].(float64) + if !ok { + pageSize = 100 + } + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + times, _, err := client.ListRepoTrackedTimes(owner, repo, gitea_sdk.ListTrackedTimesOptions{ + ListOptions: gitea_sdk.ListOptions{ + Page: int(page), + PageSize: int(pageSize), + }, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list repo tracked times for %s/%s err: %v", owner, repo, err)) + } + if len(times) == 0 { + return to.TextResult(fmt.Sprintf("No tracked times for repository %s/%s", owner, repo)) + } + return to.TextResult(times) +} + +func GetMyTimesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetMyTimesFn") + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + times, _, err := client.GetMyTrackedTimes() + if err != nil { + return to.ErrorResult(fmt.Errorf("get tracked times err: %v", err)) + } + if len(times) == 0 { + return to.TextResult("No tracked times found") + } + return to.TextResult(times) +}