diff --git a/README.md b/README.md index 840aaaf..9930730 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,33 @@ The Gitea MCP Server supports the following tools: | edit_org_label | Organization | Edit a label in an organization | | delete_org_label | Organization | Delete a label in an organization | | search_repos | Repository | Search for repositories | +| list_repo_action_secrets | Actions | List repository Actions secrets (metadata only) | +| upsert_repo_action_secret | Actions | Create/update (upsert) a repository Actions secret | +| delete_repo_action_secret | Actions | Delete a repository Actions secret | +| list_org_action_secrets | Actions | List organization Actions secrets (metadata only) | +| upsert_org_action_secret | Actions | Create/update (upsert) an organization Actions secret | +| delete_org_action_secret | Actions | Delete an organization Actions secret | +| list_repo_action_variables | Actions | List repository Actions variables | +| get_repo_action_variable | Actions | Get a repository Actions variable | +| create_repo_action_variable | Actions | Create a repository Actions variable | +| update_repo_action_variable | Actions | Update a repository Actions variable | +| delete_repo_action_variable | Actions | Delete a repository Actions variable | +| list_org_action_variables | Actions | List organization Actions variables | +| get_org_action_variable | Actions | Get an organization Actions variable | +| create_org_action_variable | Actions | Create an organization Actions variable | +| update_org_action_variable | Actions | Update an organization Actions variable | +| delete_org_action_variable | Actions | Delete an organization Actions variable | +| list_repo_action_workflows | Actions | List repository Actions workflows | +| get_repo_action_workflow | Actions | Get a repository Actions workflow | +| dispatch_repo_action_workflow| Actions | Trigger (dispatch) a repository Actions workflow | +| list_repo_action_runs | Actions | List repository Actions runs | +| get_repo_action_run | Actions | Get a repository Actions run | +| cancel_repo_action_run | Actions | Cancel a repository Actions run | +| rerun_repo_action_run | Actions | Rerun a repository Actions run | +| list_repo_action_jobs | Actions | List repository Actions jobs | +| list_repo_action_run_jobs | Actions | List Actions jobs for a run | +| get_repo_action_job_log_preview | Actions | Get a job log preview (tail/limited) | +| download_repo_action_job_log | Actions | Download a job log to a file | | get_gitea_mcp_server_version | Server | Get the version of the Gitea MCP Server | | list_wiki_pages | Wiki | List all wiki pages in a repository | | get_wiki_page | Wiki | Get a wiki page content and metadata | diff --git a/operation/actions/actions.go b/operation/actions/actions.go new file mode 100644 index 0000000..ebffe70 --- /dev/null +++ b/operation/actions/actions.go @@ -0,0 +1,10 @@ +package actions + +import ( + "gitea.com/gitea/gitea-mcp/pkg/tool" +) + +// Tool is the registry for all Actions-related MCP tools. +var Tool = tool.New() + + diff --git a/operation/actions/logs.go b/operation/actions/logs.go new file mode 100644 index 0000000..188b83a --- /dev/null +++ b/operation/actions/logs.go @@ -0,0 +1,198 @@ +package actions + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/to" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + GetRepoActionJobLogPreviewToolName = "get_repo_action_job_log_preview" + DownloadRepoActionJobLogToolName = "download_repo_action_job_log" +) + +var ( + GetRepoActionJobLogPreviewTool = mcp.NewTool( + GetRepoActionJobLogPreviewToolName, + mcp.WithDescription("Get a repository Actions job log preview (tail/limited for chat-friendly output)"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("job_id", mcp.Required(), mcp.Description("job ID")), + mcp.WithNumber("tail_lines", mcp.Description("number of lines from the end of the log"), mcp.DefaultNumber(200), mcp.Min(1)), + mcp.WithNumber("max_bytes", mcp.Description("max bytes to return"), mcp.DefaultNumber(65536), mcp.Min(1024)), + ) + + DownloadRepoActionJobLogTool = mcp.NewTool( + DownloadRepoActionJobLogToolName, + mcp.WithDescription("Download a repository Actions job log to a file on the MCP server filesystem"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("job_id", mcp.Required(), mcp.Description("job ID")), + mcp.WithString("output_path", mcp.Description("optional output file path; if omitted, uses ~/.gitea-mcp/artifacts/actions-logs/...")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{Tool: GetRepoActionJobLogPreviewTool, Handler: GetRepoActionJobLogPreviewFn}) + Tool.RegisterRead(server.ServerTool{Tool: DownloadRepoActionJobLogTool, Handler: DownloadRepoActionJobLogFn}) +} + +func logPaths(owner, repo string, jobID int64) []string { + // Primary candidate endpoints, plus a few commonly-seen variants across versions. + // We try these in order; 404/405 falls through. + return []string{ + fmt.Sprintf("repos/%s/%s/actions/jobs/%d/logs", url.PathEscape(owner), url.PathEscape(repo), jobID), + fmt.Sprintf("repos/%s/%s/actions/jobs/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID), + fmt.Sprintf("repos/%s/%s/actions/tasks/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID), + fmt.Sprintf("repos/%s/%s/actions/task/%d/log", url.PathEscape(owner), url.PathEscape(repo), jobID), + } +} + +func fetchJobLogBytes(ctx context.Context, owner, repo string, jobID int64) ([]byte, string, error) { + var lastErr error + for _, p := range logPaths(owner, repo, jobID) { + b, _, err := gitea.DoBytes(ctx, "GET", p, nil, nil, "text/plain") + if err == nil { + return b, p, nil + } + lastErr = err + var httpErr *gitea.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == 404 || httpErr.StatusCode == 405) { + continue + } + return nil, p, err + } + return nil, "", lastErr +} + +func tailByLines(data []byte, tailLines int) []byte { + if tailLines <= 0 || len(data) == 0 { + return data + } + // Find the start index of the last N lines by scanning backwards. + lines := 0 + i := len(data) - 1 + for i >= 0 { + if data[i] == '\n' { + lines++ + if lines > tailLines { + return data[i+1:] + } + } + i-- + } + return data +} + +func limitBytes(data []byte, maxBytes int) ([]byte, bool) { + if maxBytes <= 0 { + return data, false + } + if len(data) <= maxBytes { + return data, false + } + // Keep the tail so the most recent log content is preserved. + return data[len(data)-maxBytes:], true +} + +func GetRepoActionJobLogPreviewFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetRepoActionJobLogPreviewFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + jobIDFloat, ok := req.GetArguments()["job_id"].(float64) + if !ok || jobIDFloat <= 0 { + return to.ErrorResult(fmt.Errorf("job_id is required")) + } + tailLinesFloat, _ := req.GetArguments()["tail_lines"].(float64) + maxBytesFloat, _ := req.GetArguments()["max_bytes"].(float64) + tailLines := int(tailLinesFloat) + if tailLines <= 0 { + tailLines = 200 + } + maxBytes := int(maxBytesFloat) + if maxBytes <= 0 { + maxBytes = 65536 + } + + jobID := int64(jobIDFloat) + raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID) + if err != nil { + return to.ErrorResult(fmt.Errorf("get job log err: %v", err)) + } + + tailed := tailByLines(raw, tailLines) + limited, truncated := limitBytes(tailed, maxBytes) + + return to.TextResult(map[string]any{ + "endpoint": usedPath, + "job_id": jobID, + "bytes": len(raw), + "tail_lines": tailLines, + "max_bytes": maxBytes, + "truncated": truncated, + "log": string(limited), + }) +} + +func DownloadRepoActionJobLogFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called DownloadRepoActionJobLogFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + jobIDFloat, ok := req.GetArguments()["job_id"].(float64) + if !ok || jobIDFloat <= 0 { + return to.ErrorResult(fmt.Errorf("job_id is required")) + } + outputPath, _ := req.GetArguments()["output_path"].(string) + jobID := int64(jobIDFloat) + + raw, usedPath, err := fetchJobLogBytes(ctx, owner, repo, jobID) + if err != nil { + return to.ErrorResult(fmt.Errorf("download job log err: %v", err)) + } + + if outputPath == "" { + home, _ := os.UserHomeDir() + if home == "" { + home = os.TempDir() + } + outputPath = filepath.Join(home, ".gitea-mcp", "artifacts", "actions-logs", owner, repo, fmt.Sprintf("%d.log", jobID)) + } + + if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil { + return to.ErrorResult(fmt.Errorf("create output dir err: %v", err)) + } + if err := os.WriteFile(outputPath, raw, 0o600); err != nil { + return to.ErrorResult(fmt.Errorf("write log file err: %v", err)) + } + + return to.TextResult(map[string]any{ + "endpoint": usedPath, + "job_id": jobID, + "path": outputPath, + "bytes": len(raw), + }) +} + + diff --git a/operation/actions/logs_test.go b/operation/actions/logs_test.go new file mode 100644 index 0000000..8802149 --- /dev/null +++ b/operation/actions/logs_test.go @@ -0,0 +1,24 @@ +package actions + +import "testing" + +func TestTailByLines(t *testing.T) { + in := []byte("a\nb\nc\nd\n") + got := string(tailByLines(in, 2)) + if got != "c\nd\n" { + t.Fatalf("tailByLines(...,2) = %q", got) + } +} + +func TestLimitBytesKeepsTail(t *testing.T) { + in := []byte("0123456789") + out, truncated := limitBytes(in, 4) + if !truncated { + t.Fatalf("expected truncated=true") + } + if string(out) != "6789" { + t.Fatalf("limitBytes tail = %q, want %q", string(out), "6789") + } +} + + diff --git a/operation/actions/runs.go b/operation/actions/runs.go new file mode 100644 index 0000000..a1d9ab6 --- /dev/null +++ b/operation/actions/runs.go @@ -0,0 +1,468 @@ +package actions + +import ( + "context" + "errors" + "fmt" + "net/url" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/to" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + ListRepoActionWorkflowsToolName = "list_repo_action_workflows" + GetRepoActionWorkflowToolName = "get_repo_action_workflow" + DispatchRepoActionWorkflowToolName = "dispatch_repo_action_workflow" + + ListRepoActionRunsToolName = "list_repo_action_runs" + GetRepoActionRunToolName = "get_repo_action_run" + CancelRepoActionRunToolName = "cancel_repo_action_run" + RerunRepoActionRunToolName = "rerun_repo_action_run" + + ListRepoActionJobsToolName = "list_repo_action_jobs" + ListRepoActionRunJobsToolName = "list_repo_action_run_jobs" +) + +var ( + ListRepoActionWorkflowsTool = mcp.NewTool( + ListRepoActionWorkflowsToolName, + mcp.WithDescription("List repository Actions workflows"), + 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.Min(1)), + mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)), + ) + + GetRepoActionWorkflowTool = mcp.NewTool( + GetRepoActionWorkflowToolName, + mcp.WithDescription("Get a repository Actions workflow by ID"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("workflow_id", mcp.Required(), mcp.Description("workflow ID")), + ) + + DispatchRepoActionWorkflowTool = mcp.NewTool( + DispatchRepoActionWorkflowToolName, + mcp.WithDescription("Trigger (dispatch) a repository Actions workflow"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("workflow_id", mcp.Required(), mcp.Description("workflow ID")), + mcp.WithString("ref", mcp.Required(), mcp.Description("git ref (branch or tag)")), + mcp.WithObject("inputs", mcp.Description("workflow inputs object")), + ) + + ListRepoActionRunsTool = mcp.NewTool( + ListRepoActionRunsToolName, + mcp.WithDescription("List repository Actions workflow runs"), + 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.Min(1)), + mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)), + mcp.WithString("status", mcp.Description("optional status filter")), + ) + + GetRepoActionRunTool = mcp.NewTool( + GetRepoActionRunToolName, + mcp.WithDescription("Get a repository Actions run by ID"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")), + ) + + CancelRepoActionRunTool = mcp.NewTool( + CancelRepoActionRunToolName, + mcp.WithDescription("Cancel a repository Actions run"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")), + ) + + RerunRepoActionRunTool = mcp.NewTool( + RerunRepoActionRunToolName, + mcp.WithDescription("Rerun a repository Actions run"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")), + ) + + ListRepoActionJobsTool = mcp.NewTool( + ListRepoActionJobsToolName, + mcp.WithDescription("List repository Actions jobs"), + 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.Min(1)), + mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)), + mcp.WithString("status", mcp.Description("optional status filter")), + ) + + ListRepoActionRunJobsTool = mcp.NewTool( + ListRepoActionRunJobsToolName, + mcp.WithDescription("List Actions jobs for a specific run"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithNumber("run_id", mcp.Required(), mcp.Description("run ID")), + mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(50), mcp.Min(1)), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionWorkflowsTool, Handler: ListRepoActionWorkflowsFn}) + Tool.RegisterRead(server.ServerTool{Tool: GetRepoActionWorkflowTool, Handler: GetRepoActionWorkflowFn}) + Tool.RegisterWrite(server.ServerTool{Tool: DispatchRepoActionWorkflowTool, Handler: DispatchRepoActionWorkflowFn}) + + Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionRunsTool, Handler: ListRepoActionRunsFn}) + Tool.RegisterRead(server.ServerTool{Tool: GetRepoActionRunTool, Handler: GetRepoActionRunFn}) + Tool.RegisterWrite(server.ServerTool{Tool: CancelRepoActionRunTool, Handler: CancelRepoActionRunFn}) + Tool.RegisterWrite(server.ServerTool{Tool: RerunRepoActionRunTool, Handler: RerunRepoActionRunFn}) + + Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionJobsTool, Handler: ListRepoActionJobsFn}) + Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionRunJobsTool, Handler: ListRepoActionRunJobsFn}) +} + +func doJSONWithFallback(ctx context.Context, method string, paths []string, query url.Values, body any, respOut any) (string, int, error) { + var lastErr error + for _, p := range paths { + status, err := gitea.DoJSON(ctx, method, p, query, body, respOut) + if err == nil { + return p, status, nil + } + lastErr = err + var httpErr *gitea.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == 404 || httpErr.StatusCode == 405) { + continue + } + return p, status, err + } + return "", 0, lastErr +} + +func ListRepoActionWorkflowsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListRepoActionWorkflowsFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + page, _ := req.GetArguments()["page"].(float64) + if page <= 0 { + page = 1 + } + pageSize, _ := req.GetArguments()["pageSize"].(float64) + if pageSize <= 0 { + pageSize = 50 + } + query := url.Values{} + query.Set("page", fmt.Sprintf("%d", int(page))) + query.Set("limit", fmt.Sprintf("%d", int(pageSize))) + + var result any + _, _, err := doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/workflows", url.PathEscape(owner), url.PathEscape(repo)), + }, + query, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("list action workflows err: %v", err)) + } + return to.TextResult(result) +} + +func GetRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetRepoActionWorkflowFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + workflowID, ok := req.GetArguments()["workflow_id"].(float64) + if !ok || workflowID <= 0 { + return to.ErrorResult(fmt.Errorf("workflow_id is required")) + } + + var result any + _, _, err := doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/workflows/%d", url.PathEscape(owner), url.PathEscape(repo), int64(workflowID)), + }, + nil, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("get action workflow err: %v", err)) + } + return to.TextResult(result) +} + +func DispatchRepoActionWorkflowFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called DispatchRepoActionWorkflowFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + workflowID, ok := req.GetArguments()["workflow_id"].(float64) + if !ok || workflowID <= 0 { + return to.ErrorResult(fmt.Errorf("workflow_id is required")) + } + ref, ok := req.GetArguments()["ref"].(string) + if !ok || ref == "" { + return to.ErrorResult(fmt.Errorf("ref is required")) + } + + var inputs map[string]any + if raw, exists := req.GetArguments()["inputs"]; exists { + if m, ok := raw.(map[string]any); ok { + inputs = m + } else if m, ok := raw.(map[string]interface{}); ok { + inputs = make(map[string]any, len(m)) + for k, v := range m { + inputs[k] = v + } + } + } + + body := map[string]any{ + "ref": ref, + } + if inputs != nil { + body["inputs"] = inputs + } + + _, _, err := doJSONWithFallback(ctx, "POST", + []string{ + fmt.Sprintf("repos/%s/%s/actions/workflows/%d/dispatches", url.PathEscape(owner), url.PathEscape(repo), int64(workflowID)), + fmt.Sprintf("repos/%s/%s/actions/workflows/%d/dispatch", url.PathEscape(owner), url.PathEscape(repo), int64(workflowID)), + }, + nil, body, nil, + ) + if err != nil { + var httpErr *gitea.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == 404 || httpErr.StatusCode == 405) { + return to.ErrorResult(fmt.Errorf("workflow dispatch not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode)) + } + return to.ErrorResult(fmt.Errorf("dispatch action workflow err: %v", err)) + } + return to.TextResult(map[string]any{"message": "workflow dispatched"}) +} + +func ListRepoActionRunsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListRepoActionRunsFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + page, _ := req.GetArguments()["page"].(float64) + if page <= 0 { + page = 1 + } + pageSize, _ := req.GetArguments()["pageSize"].(float64) + if pageSize <= 0 { + pageSize = 50 + } + statusFilter, _ := req.GetArguments()["status"].(string) + + query := url.Values{} + query.Set("page", fmt.Sprintf("%d", int(page))) + query.Set("limit", fmt.Sprintf("%d", int(pageSize))) + if statusFilter != "" { + query.Set("status", statusFilter) + } + + var result any + _, _, err := doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/runs", url.PathEscape(owner), url.PathEscape(repo)), + }, + query, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("list action runs err: %v", err)) + } + return to.TextResult(result) +} + +func GetRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetRepoActionRunFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + runID, ok := req.GetArguments()["run_id"].(float64) + if !ok || runID <= 0 { + return to.ErrorResult(fmt.Errorf("run_id is required")) + } + + var result any + _, _, err := doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/runs/%d", url.PathEscape(owner), url.PathEscape(repo), int64(runID)), + }, + nil, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("get action run err: %v", err)) + } + return to.TextResult(result) +} + +func CancelRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called CancelRepoActionRunFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + runID, ok := req.GetArguments()["run_id"].(float64) + if !ok || runID <= 0 { + return to.ErrorResult(fmt.Errorf("run_id is required")) + } + + _, _, err := doJSONWithFallback(ctx, "POST", + []string{ + fmt.Sprintf("repos/%s/%s/actions/runs/%d/cancel", url.PathEscape(owner), url.PathEscape(repo), int64(runID)), + }, + nil, nil, nil, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("cancel action run err: %v", err)) + } + return to.TextResult(map[string]any{"message": "run cancellation requested"}) +} + +func RerunRepoActionRunFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called RerunRepoActionRunFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + runID, ok := req.GetArguments()["run_id"].(float64) + if !ok || runID <= 0 { + return to.ErrorResult(fmt.Errorf("run_id is required")) + } + + _, _, err := doJSONWithFallback(ctx, "POST", + []string{ + fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun", url.PathEscape(owner), url.PathEscape(repo), int64(runID)), + fmt.Sprintf("repos/%s/%s/actions/runs/%d/rerun-failed-jobs", url.PathEscape(owner), url.PathEscape(repo), int64(runID)), + }, + nil, nil, nil, + ) + if err != nil { + var httpErr *gitea.HTTPError + if errors.As(err, &httpErr) && (httpErr.StatusCode == 404 || httpErr.StatusCode == 405) { + return to.ErrorResult(fmt.Errorf("workflow rerun not supported on this Gitea version (endpoint returned %d). Check https://docs.gitea.com/api/1.24/ for available Actions endpoints", httpErr.StatusCode)) + } + return to.ErrorResult(fmt.Errorf("rerun action run err: %v", err)) + } + return to.TextResult(map[string]any{"message": "run rerun requested"}) +} + +func ListRepoActionJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListRepoActionJobsFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + page, _ := req.GetArguments()["page"].(float64) + if page <= 0 { + page = 1 + } + pageSize, _ := req.GetArguments()["pageSize"].(float64) + if pageSize <= 0 { + pageSize = 50 + } + statusFilter, _ := req.GetArguments()["status"].(string) + + query := url.Values{} + query.Set("page", fmt.Sprintf("%d", int(page))) + query.Set("limit", fmt.Sprintf("%d", int(pageSize))) + if statusFilter != "" { + query.Set("status", statusFilter) + } + + var result any + _, _, err := doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/jobs", url.PathEscape(owner), url.PathEscape(repo)), + }, + query, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("list action jobs err: %v", err)) + } + return to.TextResult(result) +} + +func ListRepoActionRunJobsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListRepoActionRunJobsFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + runID, ok := req.GetArguments()["run_id"].(float64) + if !ok || runID <= 0 { + return to.ErrorResult(fmt.Errorf("run_id is required")) + } + page, _ := req.GetArguments()["page"].(float64) + if page <= 0 { + page = 1 + } + pageSize, _ := req.GetArguments()["pageSize"].(float64) + if pageSize <= 0 { + pageSize = 50 + } + + query := url.Values{} + query.Set("page", fmt.Sprintf("%d", int(page))) + query.Set("limit", fmt.Sprintf("%d", int(pageSize))) + + var result any + _, _, err := doJSONWithFallback(ctx, "GET", + []string{ + fmt.Sprintf("repos/%s/%s/actions/runs/%d/jobs", url.PathEscape(owner), url.PathEscape(repo), int64(runID)), + }, + query, nil, &result, + ) + if err != nil { + return to.ErrorResult(fmt.Errorf("list action run jobs err: %v", err)) + } + return to.TextResult(result) +} diff --git a/operation/actions/secrets.go b/operation/actions/secrets.go new file mode 100644 index 0000000..711b332 --- /dev/null +++ b/operation/actions/secrets.go @@ -0,0 +1,292 @@ +package actions + +import ( + "context" + "fmt" + "net/url" + "time" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "code.gitea.io/sdk/gitea" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + ListRepoActionSecretsToolName = "list_repo_action_secrets" + UpsertRepoActionSecretToolName = "upsert_repo_action_secret" + DeleteRepoActionSecretToolName = "delete_repo_action_secret" + ListOrgActionSecretsToolName = "list_org_action_secrets" + UpsertOrgActionSecretToolName = "upsert_org_action_secret" + DeleteOrgActionSecretToolName = "delete_org_action_secret" +) + +type secretMeta struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` +} + +var ( + ListRepoActionSecretsTool = mcp.NewTool( + ListRepoActionSecretsToolName, + mcp.WithDescription("List repository Actions secrets (metadata only; secret values are never returned)"), + 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.Min(1)), + mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100), mcp.Min(1)), + ) + + UpsertRepoActionSecretTool = mcp.NewTool( + UpsertRepoActionSecretToolName, + mcp.WithDescription("Create or update (upsert) a repository Actions secret"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("name", mcp.Required(), mcp.Description("secret name")), + mcp.WithString("data", mcp.Required(), mcp.Description("secret value")), + mcp.WithString("description", mcp.Description("secret description")), + ) + + DeleteRepoActionSecretTool = mcp.NewTool( + DeleteRepoActionSecretToolName, + mcp.WithDescription("Delete a repository Actions secret"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("secretName", mcp.Required(), mcp.Description("secret name")), + ) + + ListOrgActionSecretsTool = mcp.NewTool( + ListOrgActionSecretsToolName, + mcp.WithDescription("List organization Actions secrets (metadata only; secret values are never returned)"), + mcp.WithString("org", mcp.Required(), mcp.Description("organization name")), + mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100), mcp.Min(1)), + ) + + UpsertOrgActionSecretTool = mcp.NewTool( + UpsertOrgActionSecretToolName, + mcp.WithDescription("Create or update (upsert) an organization Actions secret"), + mcp.WithString("org", mcp.Required(), mcp.Description("organization name")), + mcp.WithString("name", mcp.Required(), mcp.Description("secret name")), + mcp.WithString("data", mcp.Required(), mcp.Description("secret value")), + mcp.WithString("description", mcp.Description("secret description")), + ) + + DeleteOrgActionSecretTool = mcp.NewTool( + DeleteOrgActionSecretToolName, + mcp.WithDescription("Delete an organization Actions secret"), + mcp.WithString("org", mcp.Required(), mcp.Description("organization name")), + mcp.WithString("secretName", mcp.Required(), mcp.Description("secret name")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionSecretsTool, Handler: ListRepoActionSecretsFn}) + Tool.RegisterWrite(server.ServerTool{Tool: UpsertRepoActionSecretTool, Handler: UpsertRepoActionSecretFn}) + Tool.RegisterWrite(server.ServerTool{Tool: DeleteRepoActionSecretTool, Handler: DeleteRepoActionSecretFn}) + + Tool.RegisterRead(server.ServerTool{Tool: ListOrgActionSecretsTool, Handler: ListOrgActionSecretsFn}) + Tool.RegisterWrite(server.ServerTool{Tool: UpsertOrgActionSecretTool, Handler: UpsertOrgActionSecretFn}) + Tool.RegisterWrite(server.ServerTool{Tool: DeleteOrgActionSecretTool, Handler: DeleteOrgActionSecretFn}) +} + +func ListRepoActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListRepoActionSecretsFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + page, _ := req.GetArguments()["page"].(float64) + if page <= 0 { + page = 1 + } + pageSize, _ := req.GetArguments()["pageSize"].(float64) + if pageSize <= 0 { + pageSize = 100 + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + secrets, _, err := client.ListRepoActionSecret(owner, repo, gitea_sdk.ListRepoActionSecretOption{ + ListOptions: gitea_sdk.ListOptions{Page: int(page), PageSize: int(pageSize)}, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list repo action secrets err: %v", err)) + } + + metas := make([]secretMeta, 0, len(secrets)) + for _, s := range secrets { + if s == nil { + continue + } + metas = append(metas, secretMeta{ + Name: s.Name, + Description: s.Description, + CreatedAt: s.Created, + }) + } + return to.TextResult(metas) +} + +func UpsertRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called UpsertRepoActionSecretFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + name, ok := req.GetArguments()["name"].(string) + if !ok || name == "" { + return to.ErrorResult(fmt.Errorf("name is required")) + } + data, ok := req.GetArguments()["data"].(string) + if !ok || data == "" { + return to.ErrorResult(fmt.Errorf("data is required")) + } + description, _ := req.GetArguments()["description"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.CreateRepoActionSecret(owner, repo, gitea_sdk.CreateSecretOption{ + Name: name, + Data: data, + Description: description, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("upsert repo action secret err: %v", err)) + } + return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode}) +} + +func DeleteRepoActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called DeleteRepoActionSecretFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + secretName, ok := req.GetArguments()["secretName"].(string) + if !ok || secretName == "" { + return to.ErrorResult(fmt.Errorf("secretName is required")) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.DeleteRepoActionSecret(owner, repo, secretName) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete repo action secret err: %v", err)) + } + return to.TextResult(map[string]any{"message": "secret deleted", "status": resp.StatusCode}) +} + +func ListOrgActionSecretsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListOrgActionSecretsFn") + org, ok := req.GetArguments()["org"].(string) + if !ok || org == "" { + return to.ErrorResult(fmt.Errorf("org is required")) + } + page, _ := req.GetArguments()["page"].(float64) + if page <= 0 { + page = 1 + } + pageSize, _ := req.GetArguments()["pageSize"].(float64) + if pageSize <= 0 { + pageSize = 100 + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + + secrets, _, err := client.ListOrgActionSecret(org, gitea_sdk.ListOrgActionSecretOption{ + ListOptions: gitea_sdk.ListOptions{Page: int(page), PageSize: int(pageSize)}, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list org action secrets err: %v", err)) + } + + metas := make([]secretMeta, 0, len(secrets)) + for _, s := range secrets { + if s == nil { + continue + } + metas = append(metas, secretMeta{ + Name: s.Name, + Description: s.Description, + CreatedAt: s.Created, + }) + } + return to.TextResult(metas) +} + +func UpsertOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called UpsertOrgActionSecretFn") + org, ok := req.GetArguments()["org"].(string) + if !ok || org == "" { + return to.ErrorResult(fmt.Errorf("org is required")) + } + name, ok := req.GetArguments()["name"].(string) + if !ok || name == "" { + return to.ErrorResult(fmt.Errorf("name is required")) + } + data, ok := req.GetArguments()["data"].(string) + if !ok || data == "" { + return to.ErrorResult(fmt.Errorf("data is required")) + } + description, _ := req.GetArguments()["description"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.CreateOrgActionSecret(org, gitea_sdk.CreateSecretOption{ + Name: name, + Data: data, + Description: description, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("upsert org action secret err: %v", err)) + } + return to.TextResult(map[string]any{"message": "secret upserted", "status": resp.StatusCode}) +} + +func DeleteOrgActionSecretFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called DeleteOrgActionSecretFn") + org, ok := req.GetArguments()["org"].(string) + if !ok || org == "" { + return to.ErrorResult(fmt.Errorf("org is required")) + } + secretName, ok := req.GetArguments()["secretName"].(string) + if !ok || secretName == "" { + return to.ErrorResult(fmt.Errorf("secretName is required")) + } + + escapedOrg := url.PathEscape(org) + escapedSecret := url.PathEscape(secretName) + _, err := gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/secrets/%s", escapedOrg, escapedSecret), nil, nil, nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete org action secret err: %v", err)) + } + return to.TextResult(map[string]any{"message": "secret deleted"}) +} diff --git a/operation/actions/variables.go b/operation/actions/variables.go new file mode 100644 index 0000000..98219ec --- /dev/null +++ b/operation/actions/variables.go @@ -0,0 +1,402 @@ +package actions + +import ( + "context" + "fmt" + "net/url" + + "gitea.com/gitea/gitea-mcp/pkg/gitea" + "gitea.com/gitea/gitea-mcp/pkg/log" + "gitea.com/gitea/gitea-mcp/pkg/to" + + gitea_sdk "code.gitea.io/sdk/gitea" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + ListRepoActionVariablesToolName = "list_repo_action_variables" + GetRepoActionVariableToolName = "get_repo_action_variable" + CreateRepoActionVariableToolName = "create_repo_action_variable" + UpdateRepoActionVariableToolName = "update_repo_action_variable" + DeleteRepoActionVariableToolName = "delete_repo_action_variable" + + ListOrgActionVariablesToolName = "list_org_action_variables" + GetOrgActionVariableToolName = "get_org_action_variable" + CreateOrgActionVariableToolName = "create_org_action_variable" + UpdateOrgActionVariableToolName = "update_org_action_variable" + DeleteOrgActionVariableToolName = "delete_org_action_variable" +) + +var ( + ListRepoActionVariablesTool = mcp.NewTool( + ListRepoActionVariablesToolName, + mcp.WithDescription("List repository Actions variables"), + 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.Min(1)), + mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100), mcp.Min(1)), + ) + + GetRepoActionVariableTool = mcp.NewTool( + GetRepoActionVariableToolName, + mcp.WithDescription("Get a repository Actions variable by name"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("name", mcp.Required(), mcp.Description("variable name")), + ) + + CreateRepoActionVariableTool = mcp.NewTool( + CreateRepoActionVariableToolName, + mcp.WithDescription("Create a repository Actions variable"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("name", mcp.Required(), mcp.Description("variable name")), + mcp.WithString("value", mcp.Required(), mcp.Description("variable value")), + ) + + UpdateRepoActionVariableTool = mcp.NewTool( + UpdateRepoActionVariableToolName, + mcp.WithDescription("Update a repository Actions variable"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("name", mcp.Required(), mcp.Description("variable name")), + mcp.WithString("value", mcp.Required(), mcp.Description("new variable value")), + ) + + DeleteRepoActionVariableTool = mcp.NewTool( + DeleteRepoActionVariableToolName, + mcp.WithDescription("Delete a repository Actions variable"), + mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")), + mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")), + mcp.WithString("name", mcp.Required(), mcp.Description("variable name")), + ) + + ListOrgActionVariablesTool = mcp.NewTool( + ListOrgActionVariablesToolName, + mcp.WithDescription("List organization Actions variables"), + mcp.WithString("org", mcp.Required(), mcp.Description("organization name")), + mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1), mcp.Min(1)), + mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100), mcp.Min(1)), + ) + + GetOrgActionVariableTool = mcp.NewTool( + GetOrgActionVariableToolName, + mcp.WithDescription("Get an organization Actions variable by name"), + mcp.WithString("org", mcp.Required(), mcp.Description("organization name")), + mcp.WithString("name", mcp.Required(), mcp.Description("variable name")), + ) + + CreateOrgActionVariableTool = mcp.NewTool( + CreateOrgActionVariableToolName, + mcp.WithDescription("Create an organization Actions variable"), + mcp.WithString("org", mcp.Required(), mcp.Description("organization name")), + mcp.WithString("name", mcp.Required(), mcp.Description("variable name")), + mcp.WithString("value", mcp.Required(), mcp.Description("variable value")), + mcp.WithString("description", mcp.Description("variable description")), + ) + + UpdateOrgActionVariableTool = mcp.NewTool( + UpdateOrgActionVariableToolName, + mcp.WithDescription("Update an organization Actions variable"), + mcp.WithString("org", mcp.Required(), mcp.Description("organization name")), + mcp.WithString("name", mcp.Required(), mcp.Description("variable name")), + mcp.WithString("value", mcp.Required(), mcp.Description("new variable value")), + mcp.WithString("description", mcp.Description("new variable description")), + ) + + DeleteOrgActionVariableTool = mcp.NewTool( + DeleteOrgActionVariableToolName, + mcp.WithDescription("Delete an organization Actions variable"), + mcp.WithString("org", mcp.Required(), mcp.Description("organization name")), + mcp.WithString("name", mcp.Required(), mcp.Description("variable name")), + ) +) + +func init() { + Tool.RegisterRead(server.ServerTool{Tool: ListRepoActionVariablesTool, Handler: ListRepoActionVariablesFn}) + Tool.RegisterRead(server.ServerTool{Tool: GetRepoActionVariableTool, Handler: GetRepoActionVariableFn}) + Tool.RegisterWrite(server.ServerTool{Tool: CreateRepoActionVariableTool, Handler: CreateRepoActionVariableFn}) + Tool.RegisterWrite(server.ServerTool{Tool: UpdateRepoActionVariableTool, Handler: UpdateRepoActionVariableFn}) + Tool.RegisterWrite(server.ServerTool{Tool: DeleteRepoActionVariableTool, Handler: DeleteRepoActionVariableFn}) + + Tool.RegisterRead(server.ServerTool{Tool: ListOrgActionVariablesTool, Handler: ListOrgActionVariablesFn}) + Tool.RegisterRead(server.ServerTool{Tool: GetOrgActionVariableTool, Handler: GetOrgActionVariableFn}) + Tool.RegisterWrite(server.ServerTool{Tool: CreateOrgActionVariableTool, Handler: CreateOrgActionVariableFn}) + Tool.RegisterWrite(server.ServerTool{Tool: UpdateOrgActionVariableTool, Handler: UpdateOrgActionVariableFn}) + Tool.RegisterWrite(server.ServerTool{Tool: DeleteOrgActionVariableTool, Handler: DeleteOrgActionVariableFn}) +} + +func ListRepoActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListRepoActionVariablesFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + page, _ := req.GetArguments()["page"].(float64) + if page <= 0 { + page = 1 + } + pageSize, _ := req.GetArguments()["pageSize"].(float64) + if pageSize <= 0 { + pageSize = 100 + } + + query := url.Values{} + query.Set("page", fmt.Sprintf("%d", int(page))) + query.Set("limit", fmt.Sprintf("%d", int(pageSize))) + + var result any + _, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/actions/variables", url.PathEscape(owner), url.PathEscape(repo)), query, nil, &result) + if err != nil { + return to.ErrorResult(fmt.Errorf("list repo action variables err: %v", err)) + } + return to.TextResult(result) +} + +func GetRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetRepoActionVariableFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + name, ok := req.GetArguments()["name"].(string) + if !ok || name == "" { + return to.ErrorResult(fmt.Errorf("name is required")) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + variable, _, err := client.GetRepoActionVariable(owner, repo, name) + if err != nil { + return to.ErrorResult(fmt.Errorf("get repo action variable err: %v", err)) + } + return to.TextResult(variable) +} + +func CreateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called CreateRepoActionVariableFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + name, ok := req.GetArguments()["name"].(string) + if !ok || name == "" { + return to.ErrorResult(fmt.Errorf("name is required")) + } + value, ok := req.GetArguments()["value"].(string) + if !ok || value == "" { + return to.ErrorResult(fmt.Errorf("value is required")) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.CreateRepoActionVariable(owner, repo, name, value) + if err != nil { + return to.ErrorResult(fmt.Errorf("create repo action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode}) +} + +func UpdateRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called UpdateRepoActionVariableFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + name, ok := req.GetArguments()["name"].(string) + if !ok || name == "" { + return to.ErrorResult(fmt.Errorf("name is required")) + } + value, ok := req.GetArguments()["value"].(string) + if !ok || value == "" { + return to.ErrorResult(fmt.Errorf("value is required")) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.UpdateRepoActionVariable(owner, repo, name, value) + if err != nil { + return to.ErrorResult(fmt.Errorf("update repo action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode}) +} + +func DeleteRepoActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called DeleteRepoActionVariableFn") + owner, ok := req.GetArguments()["owner"].(string) + if !ok || owner == "" { + return to.ErrorResult(fmt.Errorf("owner is required")) + } + repo, ok := req.GetArguments()["repo"].(string) + if !ok || repo == "" { + return to.ErrorResult(fmt.Errorf("repo is required")) + } + name, ok := req.GetArguments()["name"].(string) + if !ok || name == "" { + return to.ErrorResult(fmt.Errorf("name is required")) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.DeleteRepoActionVariable(owner, repo, name) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete repo action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable deleted", "status": resp.StatusCode}) +} + +func ListOrgActionVariablesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called ListOrgActionVariablesFn") + org, ok := req.GetArguments()["org"].(string) + if !ok || org == "" { + return to.ErrorResult(fmt.Errorf("org is required")) + } + page, _ := req.GetArguments()["page"].(float64) + if page <= 0 { + page = 1 + } + pageSize, _ := req.GetArguments()["pageSize"].(float64) + if pageSize <= 0 { + pageSize = 100 + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + variables, _, err := client.ListOrgActionVariable(org, gitea_sdk.ListOrgActionVariableOption{ + ListOptions: gitea_sdk.ListOptions{Page: int(page), PageSize: int(pageSize)}, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("list org action variables err: %v", err)) + } + return to.TextResult(variables) +} + +func GetOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called GetOrgActionVariableFn") + org, ok := req.GetArguments()["org"].(string) + if !ok || org == "" { + return to.ErrorResult(fmt.Errorf("org is required")) + } + name, ok := req.GetArguments()["name"].(string) + if !ok || name == "" { + return to.ErrorResult(fmt.Errorf("name is required")) + } + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + variable, _, err := client.GetOrgActionVariable(org, name) + if err != nil { + return to.ErrorResult(fmt.Errorf("get org action variable err: %v", err)) + } + return to.TextResult(variable) +} + +func CreateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called CreateOrgActionVariableFn") + org, ok := req.GetArguments()["org"].(string) + if !ok || org == "" { + return to.ErrorResult(fmt.Errorf("org is required")) + } + name, ok := req.GetArguments()["name"].(string) + if !ok || name == "" { + return to.ErrorResult(fmt.Errorf("name is required")) + } + value, ok := req.GetArguments()["value"].(string) + if !ok || value == "" { + return to.ErrorResult(fmt.Errorf("value is required")) + } + description, _ := req.GetArguments()["description"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.CreateOrgActionVariable(org, gitea_sdk.CreateOrgActionVariableOption{ + Name: name, + Value: value, + Description: description, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("create org action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable created", "status": resp.StatusCode}) +} + +func UpdateOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called UpdateOrgActionVariableFn") + org, ok := req.GetArguments()["org"].(string) + if !ok || org == "" { + return to.ErrorResult(fmt.Errorf("org is required")) + } + name, ok := req.GetArguments()["name"].(string) + if !ok || name == "" { + return to.ErrorResult(fmt.Errorf("name is required")) + } + value, ok := req.GetArguments()["value"].(string) + if !ok || value == "" { + return to.ErrorResult(fmt.Errorf("value is required")) + } + description, _ := req.GetArguments()["description"].(string) + + client, err := gitea.ClientFromContext(ctx) + if err != nil { + return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) + } + resp, err := client.UpdateOrgActionVariable(org, name, gitea_sdk.UpdateOrgActionVariableOption{ + Value: value, + Description: description, + }) + if err != nil { + return to.ErrorResult(fmt.Errorf("update org action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable updated", "status": resp.StatusCode}) +} + +func DeleteOrgActionVariableFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + log.Debugf("Called DeleteOrgActionVariableFn") + org, ok := req.GetArguments()["org"].(string) + if !ok || org == "" { + return to.ErrorResult(fmt.Errorf("org is required")) + } + name, ok := req.GetArguments()["name"].(string) + if !ok || name == "" { + return to.ErrorResult(fmt.Errorf("name is required")) + } + + _, err := gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("orgs/%s/actions/variables/%s", url.PathEscape(org), url.PathEscape(name)), nil, nil, nil) + if err != nil { + return to.ErrorResult(fmt.Errorf("delete org action variable err: %v", err)) + } + return to.TextResult(map[string]any{"message": "variable deleted"}) +} + + diff --git a/operation/operation.go b/operation/operation.go index d127b90..096adae 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/actions" "gitea.com/gitea/gitea-mcp/operation/pull" "gitea.com/gitea/gitea-mcp/operation/repo" "gitea.com/gitea/gitea-mcp/operation/search" @@ -32,6 +33,9 @@ func RegisterTool(s *server.MCPServer) { // User Tool s.AddTools(user.Tool.Tools()...) + // Actions Tool + s.AddTools(actions.Tool.Tools()...) + // Repo Tool s.AddTools(repo.Tool.Tools()...) diff --git a/operation/wiki/wiki.go b/operation/wiki/wiki.go index e00016f..13aeb13 100644 --- a/operation/wiki/wiki.go +++ b/operation/wiki/wiki.go @@ -2,14 +2,9 @@ package wiki import ( "context" - "encoding/json" "fmt" - "io" - "net/http" "net/url" - "strings" - "gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/gitea" "gitea.com/gitea/gitea-mcp/pkg/log" "gitea.com/gitea/gitea-mcp/pkg/to" @@ -122,13 +117,9 @@ func ListWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToo return to.ErrorResult(fmt.Errorf("repo is required")) } - client, err := gitea.ClientFromContext(ctx) - if err != nil { - return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) - } - // Use direct HTTP request because SDK does not support yet wikis - result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/pages", url.QueryEscape(owner), url.QueryEscape(repo)), nil) + var result any + _, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/pages", url.QueryEscape(owner), url.QueryEscape(repo)), nil, nil, &result) if err != nil { return to.ErrorResult(fmt.Errorf("list wiki pages err: %v", err)) } @@ -151,12 +142,8 @@ func GetWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolR return to.ErrorResult(fmt.Errorf("pageName is required")) } - client, err := gitea.ClientFromContext(ctx) - if err != nil { - return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) - } - - result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil) + var result any + _, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil, nil, &result) if err != nil { return to.ErrorResult(fmt.Errorf("get wiki page err: %v", err)) } @@ -179,12 +166,8 @@ func GetWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.Call return to.ErrorResult(fmt.Errorf("pageName is required")) } - client, err := gitea.ClientFromContext(ctx) - if err != nil { - return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) - } - - result, err := makeWikiAPIRequest(ctx, client, "GET", fmt.Sprintf("repos/%s/%s/wiki/revisions/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil) + var result any + _, err := gitea.DoJSON(ctx, "GET", fmt.Sprintf("repos/%s/%s/wiki/revisions/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil, nil, &result) if err != nil { return to.ErrorResult(fmt.Errorf("get wiki revisions err: %v", err)) } @@ -222,12 +205,8 @@ func CreateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo "message": message, } - client, err := gitea.ClientFromContext(ctx) - if err != nil { - return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) - } - - result, err := makeWikiAPIRequest(ctx, client, "POST", fmt.Sprintf("repos/%s/%s/wiki/new", url.QueryEscape(owner), url.QueryEscape(repo)), requestBody) + var result any + _, err := gitea.DoJSON(ctx, "POST", fmt.Sprintf("repos/%s/%s/wiki/new", url.QueryEscape(owner), url.QueryEscape(repo)), nil, requestBody, &result) if err != nil { return to.ErrorResult(fmt.Errorf("create wiki page err: %v", err)) } @@ -272,12 +251,8 @@ func UpdateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo requestBody["message"] = fmt.Sprintf("Update wiki page '%s'", pageName) } - client, err := gitea.ClientFromContext(ctx) - if err != nil { - return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) - } - - result, err := makeWikiAPIRequest(ctx, client, "PATCH", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), requestBody) + var result any + _, err := gitea.DoJSON(ctx, "PATCH", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil, requestBody, &result) if err != nil { return to.ErrorResult(fmt.Errorf("update wiki page err: %v", err)) } @@ -300,64 +275,10 @@ func DeleteWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallTo return to.ErrorResult(fmt.Errorf("pageName is required")) } - client, err := gitea.ClientFromContext(ctx) - if err != nil { - return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err)) - } - - _, err = makeWikiAPIRequest(ctx, client, "DELETE", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil) + _, err := gitea.DoJSON(ctx, "DELETE", fmt.Sprintf("repos/%s/%s/wiki/page/%s", url.QueryEscape(owner), url.QueryEscape(repo), url.QueryEscape(pageName)), nil, nil, nil) if err != nil { return to.ErrorResult(fmt.Errorf("delete wiki page err: %v", err)) } return to.TextResult(map[string]string{"message": "Wiki page deleted successfully"}) } - -// Helper function to make HTTP requests to Gitea Wiki API -func makeWikiAPIRequest(ctx context.Context, client interface{}, method, path string, body interface{}) (interface{}, error) { - // Use flags to get base URL and token - apiURL := fmt.Sprintf("%s/api/v1/%s", flag.Host, path) - - httpClient := &http.Client{} - - var reqBody io.Reader - if body != nil { - bodyBytes, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - reqBody = strings.NewReader(string(bodyBytes)) - } - - req, err := http.NewRequestWithContext(ctx, method, apiURL, reqBody) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", fmt.Sprintf("token %s", flag.Token)) - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to make request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("API request failed with status %d", resp.StatusCode) - } - - if method == "DELETE" { - return map[string]string{"message": "success"}, nil - } - - var result interface{} - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - return result, nil -} diff --git a/pkg/gitea/rest.go b/pkg/gitea/rest.go new file mode 100644 index 0000000..fa69e9a --- /dev/null +++ b/pkg/gitea/rest.go @@ -0,0 +1,175 @@ +package gitea + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" + "gitea.com/gitea/gitea-mcp/pkg/flag" +) + +type HTTPError struct { + StatusCode int + Body string +} + +func (e *HTTPError) Error() string { + if e.Body == "" { + return fmt.Sprintf("request failed with status %d", e.StatusCode) + } + return fmt.Sprintf("request failed with status %d: %s", e.StatusCode, e.Body) +} + +func tokenFromContext(ctx context.Context) string { + if ctx != nil { + if token, ok := ctx.Value(mcpContext.TokenContextKey).(string); ok && token != "" { + return token + } + } + return flag.Token +} + +func newRESTHTTPClient() *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + if flag.Insecure { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec + } + return &http.Client{ + Transport: transport, + Timeout: 60 * time.Second, + } +} + +func buildAPIURL(path string, query url.Values) (string, error) { + host := strings.TrimRight(flag.Host, "/") + if host == "" { + return "", fmt.Errorf("gitea host is empty") + } + p := strings.TrimLeft(path, "/") + u, err := url.Parse(fmt.Sprintf("%s/api/v1/%s", host, p)) + if err != nil { + return "", err + } + if query != nil { + u.RawQuery = query.Encode() + } + return u.String(), nil +} + +// DoJSON performs an API request and decodes a JSON response into respOut (if non-nil). +// It returns the HTTP status code. +func DoJSON(ctx context.Context, method, path string, query url.Values, body any, respOut any) (int, error) { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return 0, fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + u, err := buildAPIURL(path, query) + if err != nil { + return 0, err + } + req, err := http.NewRequestWithContext(ctx, method, u, bodyReader) + if err != nil { + return 0, fmt.Errorf("create request: %w", err) + } + + token := tokenFromContext(ctx) + if token != "" { + req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + client := newRESTHTTPClient() + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodySnippet, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) + return resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))} + } + + if respOut == nil { + io.Copy(io.Discard, resp.Body) // best-effort + return resp.StatusCode, nil + } + + if err := json.NewDecoder(resp.Body).Decode(respOut); err != nil { + return resp.StatusCode, fmt.Errorf("decode response: %w", err) + } + return resp.StatusCode, nil +} + +// DoBytes performs an API request and returns the raw response bytes. +// It returns the HTTP status code. +func DoBytes(ctx context.Context, method, path string, query url.Values, body any, accept string) ([]byte, int, error) { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, 0, fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + u, err := buildAPIURL(path, query) + if err != nil { + return nil, 0, err + } + req, err := http.NewRequestWithContext(ctx, method, u, bodyReader) + if err != nil { + return nil, 0, fmt.Errorf("create request: %w", err) + } + + token := tokenFromContext(ctx) + if token != "" { + req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) + } + if accept != "" { + req.Header.Set("Accept", accept) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + client := newRESTHTTPClient() + resp, err := client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodySnippet := respBytes + if len(bodySnippet) > 8192 { + bodySnippet = bodySnippet[:8192] + } + return nil, resp.StatusCode, &HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(bodySnippet))} + } + + return respBytes, resp.StatusCode, nil +} + + diff --git a/pkg/gitea/rest_test.go b/pkg/gitea/rest_test.go new file mode 100644 index 0000000..579cc34 --- /dev/null +++ b/pkg/gitea/rest_test.go @@ -0,0 +1,32 @@ +package gitea + +import ( + "context" + "testing" + + mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" + "gitea.com/gitea/gitea-mcp/pkg/flag" +) + +func TestTokenFromContext(t *testing.T) { + orig := flag.Token + defer func() { flag.Token = orig }() + + flag.Token = "flag-token" + + t.Run("context token wins", func(t *testing.T) { + ctx := context.WithValue(context.Background(), mcpContext.TokenContextKey, "ctx-token") + if got := tokenFromContext(ctx); got != "ctx-token" { + t.Fatalf("tokenFromContext() = %q, want %q", got, "ctx-token") + } + }) + + t.Run("fallback to flag token", func(t *testing.T) { + ctx := context.Background() + if got := tokenFromContext(ctx); got != "flag-token" { + t.Fatalf("tokenFromContext() = %q, want %q", got, "flag-token") + } + }) +} + +