Files
gitea-mcp/operation/wiki/wiki.go
Shawn Anderson 17119bcab6 feat: add Gitea Actions support (secrets, variables, workflows, runs, jobs, logs) (#110)
# Add Gitea Actions support (secrets, variables, workflows, runs, jobs, logs)

## Summary

This PR adds comprehensive support for Gitea Actions API to the MCP server, enabling users to manage Actions secrets, variables, workflows, runs, jobs, and logs through the Model Context Protocol interface.

## New Features

### Actions Secrets (Repository & Organization Level)
- `list_repo_action_secrets` - List repository secrets (metadata only, values never exposed)
- `upsert_repo_action_secret` - Create or update a repository secret
- `delete_repo_action_secret` - Delete a repository secret
- `list_org_action_secrets` - List organization secrets
- `upsert_org_action_secret` - Create or update an organization secret
- `delete_org_action_secret` - Delete an organization secret

### Actions Variables (Repository & Organization Level)
- `list_repo_action_variables` - List repository variables
- `get_repo_action_variable` - Get a specific repository variable
- `create_repo_action_variable` - Create a repository variable
- `update_repo_action_variable` - Update a repository variable
- `delete_repo_action_variable` - Delete a repository variable
- `list_org_action_variables` - List organization variables
- `get_org_action_variable` - Get a specific organization variable
- `create_org_action_variable` - Create an organization variable
- `update_org_action_variable` - Update an organization variable
- `delete_org_action_variable` - Delete an organization variable

### Actions Workflows
- `list_repo_action_workflows` - List repository workflows
- `get_repo_action_workflow` - Get a specific workflow by ID
- `dispatch_repo_action_workflow` - Trigger (dispatch) a workflow run with optional inputs

### Actions Runs
- `list_repo_action_runs` - List workflow runs with optional status filtering
- `get_repo_action_run` - Get a specific run by ID
- `cancel_repo_action_run` - Cancel a running workflow
- `rerun_repo_action_run` - Rerun a workflow (with fallback routes for version compatibility)

### Actions Jobs
- `list_repo_action_jobs` - List all jobs in a repository
- `list_repo_action_run_jobs` - List jobs for a specific workflow run

### Actions Job Logs
- `get_repo_action_job_log_preview` - Get log preview with tail/limit support (chat-friendly)
- `download_repo_action_job_log` - Download full job logs to file (default: `~/.gitea-mcp/artifacts/actions-logs/`)

## Implementation Details

### Architecture
- Follows existing codebase patterns: new `operation/actions/` package with tools registered via `Tool.RegisterRead/Write()`
- Uses Gitea SDK (`code.gitea.io/sdk/gitea v0.22.1`) where endpoints are available
- Shared REST helper (`pkg/gitea/rest.go`) for endpoints not yet in SDK (workflows, runs, jobs, logs)

### Security
- **Secrets never expose values**: List/get operations return only safe metadata (name, description, created_at)
- Request-scoped token support: HTTP Bearer tokens properly respected (fixes issue where wiki REST calls were hardcoding `flag.Token`)

### Compatibility
- Fallback route logic for dispatch/rerun endpoints (handles Gitea version differences)
- Clear error messages when endpoints aren't available, referencing Gitea 1.24 API docs
- Graceful handling of 404/405 responses for unsupported endpoints

### Testing
- Unit tests for REST helper token precedence
- Unit tests for log truncation/formatting helpers
- All existing tests pass

## Files Changed

- **New**: `operation/actions/*` - Complete Actions module (secrets, variables, runs, logs)
- **New**: `pkg/gitea/rest.go` - Shared REST helper with token context support
- **New**: `pkg/gitea/rest_test.go` - Tests for REST helper
- **Modified**: `operation/operation.go` - Register Actions tools
- **Modified**: `operation/wiki/wiki.go` - Refactored to use shared REST helper (removed hardcoded token)
- **Modified**: `README.md` - Added all new tools to documentation

## Testing

```bash
# All tests pass
go test ./...

# Build succeeds
make build
```

## Example Usage

```python
# List repository secrets
mcp.call_tool("list_repo_action_secrets", {"owner": "user", "repo": "myrepo"})

# Trigger a workflow
mcp.call_tool("dispatch_repo_action_workflow", {
    "owner": "user",
    "repo": "myrepo",
    "workflow_id": 123,
    "ref": "main",
    "inputs": {"deploy_env": "production"}
})

# Get job log preview (last 100 lines)
mcp.call_tool("get_repo_action_job_log_preview", {
    "owner": "user",
    "repo": "myrepo",
    "job_id": 456,
    "tail_lines": 100
})
```

## Breaking Changes

None - this is a purely additive change.

## Related Issues

Fixes #[issue-number] (if applicable)

## Checklist

- [x] Code follows existing patterns and conventions
- [x] All tests pass
- [x] Documentation updated (README.md)
- [x] No breaking changes
- [x] Security considerations addressed (secrets never expose values)
- [x] Error handling implemented with clear messages
- [x] Version compatibility considered (fallback routes)

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/110
Reviewed-by: hiifong <f@f.style>
Co-authored-by: Shawn Anderson <sanderson@eye-catcher.com>
Co-committed-by: Shawn Anderson <sanderson@eye-catcher.com>
2025-12-18 15:00:52 +00:00

285 lines
9.5 KiB
Go

package wiki
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.com/gitea/gitea-mcp/pkg/tool"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
ListWikiPagesToolName = "list_wiki_pages"
GetWikiPageToolName = "get_wiki_page"
GetWikiRevisionsToolName = "get_wiki_revisions"
CreateWikiPageToolName = "create_wiki_page"
UpdateWikiPageToolName = "update_wiki_page"
DeleteWikiPageToolName = "delete_wiki_page"
)
var (
ListWikiPagesTool = mcp.NewTool(
ListWikiPagesToolName,
mcp.WithDescription("List all wiki pages in a repository"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
)
GetWikiPageTool = mcp.NewTool(
GetWikiPageToolName,
mcp.WithDescription("Get a wiki page content and metadata"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name")),
)
GetWikiRevisionsTool = mcp.NewTool(
GetWikiRevisionsToolName,
mcp.WithDescription("Get revisions history of a wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name")),
)
CreateWikiPageTool = mcp.NewTool(
CreateWikiPageToolName,
mcp.WithDescription("Create a new wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("title", mcp.Required(), mcp.Description("wiki page title")),
mcp.WithString("content_base64", mcp.Required(), mcp.Description("page content, base64 encoded")),
mcp.WithString("message", mcp.Description("commit message (optional)")),
)
UpdateWikiPageTool = mcp.NewTool(
UpdateWikiPageToolName,
mcp.WithDescription("Update an existing wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Required(), mcp.Description("current wiki page name")),
mcp.WithString("title", mcp.Description("new page title (optional)")),
mcp.WithString("content_base64", mcp.Required(), mcp.Description("page content, base64 encoded")),
mcp.WithString("message", mcp.Description("commit message (optional)")),
)
DeleteWikiPageTool = mcp.NewTool(
DeleteWikiPageToolName,
mcp.WithDescription("Delete a wiki page"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("pageName", mcp.Required(), mcp.Description("wiki page name to delete")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: ListWikiPagesTool,
Handler: ListWikiPagesFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetWikiPageTool,
Handler: GetWikiPageFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: GetWikiRevisionsTool,
Handler: GetWikiRevisionsFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateWikiPageTool,
Handler: CreateWikiPageFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: UpdateWikiPageTool,
Handler: UpdateWikiPageFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteWikiPageTool,
Handler: DeleteWikiPageFn,
})
}
func ListWikiPagesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListWikiPagesFn")
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"))
}
// Use direct HTTP request because SDK does not support yet wikis
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))
}
return to.TextResult(result)
}
func GetWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetWikiPageFn")
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"))
}
pageName, ok := req.GetArguments()["pageName"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("pageName is required"))
}
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))
}
return to.TextResult(result)
}
func GetWikiRevisionsFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetWikiRevisionsFn")
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"))
}
pageName, ok := req.GetArguments()["pageName"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("pageName is required"))
}
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))
}
return to.TextResult(result)
}
func CreateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateWikiPageFn")
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"))
}
title, ok := req.GetArguments()["title"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("title is required"))
}
contentBase64, ok := req.GetArguments()["content_base64"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("content_base64 is required"))
}
message, _ := req.GetArguments()["message"].(string)
if message == "" {
message = fmt.Sprintf("Create wiki page '%s'", title)
}
requestBody := map[string]string{
"title": title,
"content_base64": contentBase64,
"message": message,
}
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))
}
return to.TextResult(result)
}
func UpdateWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called UpdateWikiPageFn")
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"))
}
pageName, ok := req.GetArguments()["pageName"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("pageName is required"))
}
contentBase64, ok := req.GetArguments()["content_base64"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("content_base64 is required"))
}
requestBody := map[string]string{
"content_base64": contentBase64,
}
// If title is given, use it. Otherwise, keep current page name
if title, ok := req.GetArguments()["title"].(string); ok && title != "" {
requestBody["title"] = title
} else {
// Utiliser pageName comme fallback pour éviter "unnamed"
requestBody["title"] = pageName
}
if message, ok := req.GetArguments()["message"].(string); ok && message != "" {
requestBody["message"] = message
} else {
requestBody["message"] = fmt.Sprintf("Update wiki page '%s'", pageName)
}
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))
}
return to.TextResult(result)
}
func DeleteWikiPageFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteWikiPageFn")
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"))
}
pageName, ok := req.GetArguments()["pageName"].(string)
if !ok {
return to.ErrorResult(fmt.Errorf("pageName is required"))
}
_, 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"})
}