4 Commits

Author SHA1 Message Date
hiifong
017ca94a86 format 2025-12-18 23:14:11 +08:00
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
Stanislav Krasnyi
8b06d7154e Fix assignees parsing in Edit Issue (#109)
# Fix assignees parsing in EditIssueFn

## Problem
The `EditIssueFn` function in `operation/issue/issue.go` had a bug where assignees were not being properly parsed from the request arguments. The code was attempting to directly cast the assignees array to `[]string`, but the MCP framework passes arrays as `[]interface{}`. This caused the assignees to appear empty when editing issues through the Gitea-MCP endpoint.

## Solution
The assignees parsing logic in the `EditIssueFn` function has been fixed to properly handle the `[]interface{}` type that comes from the MCP framework:

1. Check if the assignees argument exists in the request
2. Type-assert it to `[]interface{}`
3. Iterate through each element and convert it to string
4. Assign the properly parsed string slice to `opt.Assignees`

## Changes
- Modified `operation/issue/issue.go` in the `EditIssueFn` function
- The fix follows the same pattern used successfully in other parts of the codebase (pull/pull.go and label/label.go)

## Testing
- The fix has been implemented and tested to ensure assignees are properly parsed and applied to issues
- No existing functionality was broken
- The solution maintains backward compatibility

## Impact
This fix resolves the issue where assignees were not being set when using the Gitea-MCP endpoint `/repos/{owner}/{repo}/issues/{index} (PATCH)` to edit issues with assignees.

Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/109
Co-authored-by: Stanislav Krasnyi <stan.krasnyi@gmail.com>
Co-committed-by: Stanislav Krasnyi <stan.krasnyi@gmail.com>
2025-12-13 08:37:15 +00:00
Nassim Amar
bdd9fb1816 Milestone addition and Windows build support (#104)
## Milestone Implementation

The `milestone.go` file adds comprehensive milestone functionality to the Gitea MCP server with the following MCP tools:

### Tools Added:

1. __`get_milestone`__ - Retrieves a specific milestone by ID
2. __`list_milestones`__ - Lists repository milestones with filtering options
3. __`create_milestone`__ - Creates new milestones with title, description, and due dates
4. __`edit_milestone`__ - Modifies existing milestones including state changes
5. __`delete_milestone`__ - Removes milestones from repositories

### Integration with Other Components:

__Issue Management__:

- Issues can be associated with milestones through the `edit_issue` tool
- The `milestone` parameter (number) links issues to specific milestones
- This creates traceability between development tasks and project milestones

__Pull Request Filtering__:

- Pull requests can be filtered by milestone using the `milestone` parameter
- This enables viewing all PRs related to a specific milestone

### Key Features:

- __State Management__: Milestones support "open" and "closed" states
- __Due Dates__: Optional due dates for milestone tracking
- __Pagination__: List operations support pagination for large datasets
- __Full CRUD Operations__: Complete create, read, update, delete capabilities

### Workflow Integration:

While there's no direct commit message integration shown in the current implementation, milestones provide project planning capabilities that integrate with:

- Issue tracking (linking issues to milestones)
- Development workflow (filtering PRs by milestone)
- Project management (due dates, state tracking)

This addition enables project management capabilities within the Gitea MCP server, allowing users to organize work into milestones and track progress across issues and pull requests.

----------------------
feat: add Windows build support with PowerShell and batch scripts

Add comprehensive Windows build support including PowerShell script (build.ps1) and batch wrapper (build.bat) that replicate Makefile functionality. The scripts provide targets for building, installing, cleaning, and development with hot reload support. Also includes detailed BUILDING.md documentation for Windows users.

Co-authored-by: hiifong <i@hiif.ong>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/104
Reviewed-by: hiifong <i@hiif.ong>
Co-authored-by: Nassim Amar <namar0x0309@pm.me>
Co-committed-by: Nassim Amar <namar0x0309@pm.me>
2025-11-02 03:18:57 +00:00
16 changed files with 2267 additions and 143 deletions

63
BUILDING.md Normal file
View File

@@ -0,0 +1,63 @@
# Building gitea-mcp on Windows
This project includes PowerShell and batch scripts to build the gitea-mcp application on Windows systems.
## Prerequisites
- Go 1.24 or later
- Git (for version information)
- PowerShell 5.1 or later (included with Windows 10/11)
## Build Scripts
### PowerShell Script (`build.ps1`)
The main build script that replicates all Makefile functionality:
```powershell
# Show help
.\build.ps1 help
# Build the application
.\build.ps1 build
# Install the application
.\build.ps1 install
# Clean build artifacts
.\build.ps1 clean
# Run in development mode (hot reload)
.\build.ps1 dev
# Update vendor dependencies
.\build.ps1 vendor
```
### Batch File Wrapper (`build.bat`)
A simple wrapper to run the PowerShell script:
```cmd
# Run with default help target
build.bat
# Run specific target
build.bat build
build.bat install
```
## Available Targets
- **help** - Print help message
- **build** - Build the application executable
- **install** - Build and install to GOPATH/bin
- **uninstall** - Remove executable from GOPATH/bin
- **clean** - Remove build artifacts
- **air** - Install air for hot reload development
- **dev** - Run with hot reload development
- **vendor** - Tidy and verify Go module dependencies
## Output
The build process creates `gitea-mcp.exe` in the project directory.

View File

@@ -165,7 +165,7 @@ list all my repositories
The Gitea MCP Server supports the following tools:
| Tool | Scope | Description |
| :--------------------------: | :----------: | :------------------------------------------------------: |
| :-----------------------------: | :----------: | :------------------------------------------------------: |
| get_my_user_info | User | Get the information of the authenticated user |
| get_user_orgs | User | Get organizations associated with the authenticated user |
| create_repo | Repository | Create a new repository |
@@ -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 |

2
build.bat Normal file
View File

@@ -0,0 +1,2 @@
@echo off
powershell -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*

220
build.ps1 Normal file
View File

@@ -0,0 +1,220 @@
#!/usr/bin/env pwsh
# PowerShell build script for gitea-mcp
# Replicates the functionality of the Makefile
param(
[string]$Target = "help"
)
# Configuration
$EXECUTABLE = "gitea-mcp.exe"
$VERSION = & git describe --tags --always 2>$null | ForEach-Object { $_ -replace '-', '+' -replace '^v', '' }
if (-not $VERSION) { $VERSION = "dev" }
$LDFLAGS = "-X `"main.Version=$VERSION`""
# Colors for output (Windows PowerShell compatible)
$CYAN = "Cyan"
$RESET = "White"
function Write-Header {
param([string]$Message)
Write-Host "=== $Message ===" -ForegroundColor Green
}
function Write-Info {
param([string]$Message)
Write-Host $Message -ForegroundColor Yellow
}
function Write-Success {
param([string]$Message)
Write-Host $Message -ForegroundColor Green
}
function Write-Error {
param([string]$Message)
Write-Host $Message -ForegroundColor Red
}
function Get-Help {
Write-Host "Usage: .\build.ps1 [target]" -ForegroundColor Green
Write-Host ""
Write-Host "Targets:" -ForegroundColor Green
Write-Host ""
Write-Host ("{0,-30}" -f "help") -ForegroundColor Cyan -NoNewline
Write-Host " Print this help message."
Write-Host ("{0,-30}" -f "build") -ForegroundColor Cyan -NoNewline
Write-Host " Build the application."
Write-Host ("{0,-30}" -f "install") -ForegroundColor Cyan -NoNewline
Write-Host " Install the application."
Write-Host ("{0,-30}" -f "uninstall") -ForegroundColor Cyan -NoNewline
Write-Host " Uninstall the application."
Write-Host ("{0,-30}" -f "clean") -ForegroundColor Cyan -NoNewline
Write-Host " Clean the build artifacts."
Write-Host ("{0,-30}" -f "air") -ForegroundColor Cyan -NoNewline
Write-Host " Install air for hot reload."
Write-Host ("{0,-30}" -f "dev") -ForegroundColor Cyan -NoNewline
Write-Host " Run the application with hot reload."
Write-Host ("{0,-30}" -f "vendor") -ForegroundColor Cyan -NoNewline
Write-Host " Tidy and verify module dependencies."
}
function Build-App {
Write-Header "Building application"
$ldflags = "-s -w $LDFLAGS"
Write-Info "go build -v -ldflags '$ldflags' -o $EXECUTABLE"
try {
& go build -v -ldflags $ldflags -o $EXECUTABLE
if ($LASTEXITCODE -eq 0) {
Write-Success "Build successful: $EXECUTABLE"
} else {
Write-Error "Build failed with exit code: $LASTEXITCODE"
exit $LASTEXITCODE
}
} catch {
Write-Error "Build failed: $_"
exit 1
}
}
function Install-App {
Write-Header "Installing application"
# First build the application
Build-App
$GOPATH = $env:GOPATH
if (-not $GOPATH) {
$GOPATH = Join-Path $env:USERPROFILE "go"
}
$installDir = Join-Path $GOPATH "bin"
$installPath = Join-Path $installDir $EXECUTABLE
Write-Info "Installing $EXECUTABLE to $installPath"
# Create directory if it doesn't exist
if (-not (Test-Path $installDir)) {
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
}
# Copy the executable
if (Test-Path $EXECUTABLE) {
Copy-Item $EXECUTABLE $installPath -Force
Write-Success "Installed $EXECUTABLE to $installPath"
Write-Info "Please add $installDir to your PATH if it is not already there."
} else {
Write-Error "Executable not found. Please build first."
exit 1
}
}
function Uninstall-App {
Write-Header "Uninstalling application"
$GOPATH = $env:GOPATH
if (-not $GOPATH) {
$GOPATH = Join-Path $env:USERPROFILE "go"
}
$installPath = Join-Path $GOPATH "bin" $EXECUTABLE
Write-Info "Uninstalling $EXECUTABLE from $installPath"
if (Test-Path $installPath) {
Remove-Item $installPath -Force
Write-Success "Uninstalled $EXECUTABLE from $installPath"
} else {
Write-Warning "$EXECUTABLE not found at $installPath"
}
}
function Clean-Build {
Write-Header "Cleaning build artifacts"
Write-Info "Cleaning up $EXECUTABLE"
if (Test-Path $EXECUTABLE) {
Remove-Item $EXECUTABLE -Force
Write-Success "Cleaned up $EXECUTABLE"
} else {
Write-Warning "$EXECUTABLE not found"
}
}
function Install-Air {
Write-Header "Installing air for hot reload"
# Check if air is already installed
$airPath = Get-Command air -ErrorAction SilentlyContinue
if ($airPath) {
Write-Success "air is already installed"
return
}
Write-Info "Installing github.com/air-verse/air@latest"
try {
& go install github.com/air-verse/air@latest
if ($LASTEXITCODE -eq 0) {
Write-Success "air installed successfully"
} else {
Write-Error "Failed to install air"
exit $LASTEXITCODE
}
} catch {
Write-Error "Failed to install air: $_"
exit 1
}
}
function Start-Dev {
Write-Header "Starting development mode with hot reload"
# Install air first
Install-Air
Write-Info "Starting air with build configuration"
& air --build.cmd "go build -o $EXECUTABLE" --build.bin "./$EXECUTABLE"
}
function Update-Vendor {
Write-Header "Tidying and verifying module dependencies"
Write-Info "Running go mod tidy"
& go mod tidy
if ($LASTEXITCODE -ne 0) {
Write-Error "go mod tidy failed"
exit $LASTEXITCODE
}
Write-Info "Running go mod verify"
& go mod verify
if ($LASTEXITCODE -ne 0) {
Write-Error "go mod verify failed"
exit $LASTEXITCODE
}
Write-Success "Dependencies updated successfully"
}
# Main execution logic
switch ($Target.ToLower()) {
"help" { Get-Help }
"build" { Build-App }
"install" { Install-App }
"uninstall" { Uninstall-App }
"clean" { Clean-Build }
"air" { Install-Air }
"dev" { Start-Dev }
"vendor" { Update-Vendor }
default {
Write-Error "Unknown target: $Target"
Write-Host ""
Get-Help
exit 1
}
}

View File

@@ -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()

198
operation/actions/logs.go Normal file
View File

@@ -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),
})
}

View File

@@ -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")
}
}

468
operation/actions/runs.go Normal file
View File

@@ -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)
}

View File

@@ -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"})
}

View File

@@ -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"})
}

View File

@@ -283,10 +283,17 @@ func EditIssueFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolRes
if ok {
opt.Body = ptr.To(body)
}
assignees, ok := req.GetArguments()["assignees"].([]string)
if ok {
opt.Assignees = assignees
var assignees []string
if assigneesArg, exists := req.GetArguments()["assignees"]; exists {
if assigneesSlice, ok := assigneesArg.([]interface{}); ok {
for _, assignee := range assigneesSlice {
if assigneeStr, ok := assignee.(string); ok {
assignees = append(assignees, assigneeStr)
}
}
}
}
opt.Assignees = assignees
milestone, ok := req.GetArguments()["milestone"].(float64)
if ok {
opt.Milestone = ptr.To(int64(milestone))

View File

@@ -0,0 +1,275 @@
package milestone
import (
"context"
"fmt"
"gitea.com/gitea/gitea-mcp/pkg/gitea"
"gitea.com/gitea/gitea-mcp/pkg/log"
"gitea.com/gitea/gitea-mcp/pkg/ptr"
"gitea.com/gitea/gitea-mcp/pkg/to"
"gitea.com/gitea/gitea-mcp/pkg/tool"
gitea_sdk "code.gitea.io/sdk/gitea"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
var Tool = tool.New()
const (
GetMilestoneToolName = "get_milestone"
ListMilestonesToolName = "list_milestones"
CreateMilestoneToolName = "create_milestone"
EditMilestoneToolName = "edit_milestone"
DeleteMilestoneToolName = "delete_milestone"
)
var (
GetMilestoneTool = mcp.NewTool(
GetMilestoneToolName,
mcp.WithDescription("get milestone by id"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("milestone id")),
)
ListMilestonesTool = mcp.NewTool(
ListMilestonesToolName,
mcp.WithDescription("List milestones"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithString("state", mcp.Description("milestone state"), mcp.DefaultString("all")),
mcp.WithString("name", mcp.Description("milestone name")),
mcp.WithNumber("page", mcp.Description("page number"), mcp.DefaultNumber(1)),
mcp.WithNumber("pageSize", mcp.Description("page size"), mcp.DefaultNumber(100)),
)
CreateMilestoneTool = mcp.NewTool(
CreateMilestoneToolName,
mcp.WithDescription("create milestone"),
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("milestone title")),
mcp.WithString("description", mcp.Description("milestone description")),
mcp.WithString("due_on", mcp.Description("due date")),
)
EditMilestoneTool = mcp.NewTool(
EditMilestoneToolName,
mcp.WithDescription("edit milestone"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("milestone id")),
mcp.WithString("title", mcp.Description("milestone title")),
mcp.WithString("description", mcp.Description("milestone description")),
mcp.WithString("due_on", mcp.Description("due date")),
mcp.WithString("state", mcp.Description("milestone state, one of open, closed")),
)
DeleteMilestoneTool = mcp.NewTool(
DeleteMilestoneToolName,
mcp.WithDescription("delete milestone"),
mcp.WithString("owner", mcp.Required(), mcp.Description("repository owner")),
mcp.WithString("repo", mcp.Required(), mcp.Description("repository name")),
mcp.WithNumber("id", mcp.Required(), mcp.Description("milestone id")),
)
)
func init() {
Tool.RegisterRead(server.ServerTool{
Tool: GetMilestoneTool,
Handler: GetMilestoneFn,
})
Tool.RegisterRead(server.ServerTool{
Tool: ListMilestonesTool,
Handler: ListMilestonesFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: CreateMilestoneTool,
Handler: CreateMilestoneFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: EditMilestoneTool,
Handler: EditMilestoneFn,
})
Tool.RegisterWrite(server.ServerTool{
Tool: DeleteMilestoneTool,
Handler: DeleteMilestoneFn,
})
}
func GetMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called GetMilestoneFn")
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"))
}
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))
}
milestone, _, err := client.GetMilestone(owner, repo, int64(id))
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/milestone/%v err: %v", owner, repo, int64(id), err))
}
return to.TextResult(milestone)
}
func ListMilestonesFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called ListMilestonesFn")
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"))
}
state, ok := req.GetArguments()["state"].(string)
if !ok {
state = "all"
}
name, ok := req.GetArguments()["name"].(string)
if !ok {
name = ""
}
page, ok := req.GetArguments()["page"].(float64)
if !ok {
page = 1
}
pageSize, ok := req.GetArguments()["pageSize"].(float64)
if !ok {
pageSize = 100
}
opt := gitea_sdk.ListMilestoneOption{
State: gitea_sdk.StateType(state),
Name: name,
ListOptions: gitea_sdk.ListOptions{
Page: int(page),
PageSize: int(pageSize),
},
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestones, _, err := client.ListRepoMilestones(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("get %v/%v/milestones err: %v", owner, repo, err))
}
return to.TextResult(milestones)
}
func CreateMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called CreateMilestoneFn")
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"))
}
opt := gitea_sdk.CreateMilestoneOption{
Title: title,
}
description, ok := req.GetArguments()["description"].(string)
if ok {
opt.Description = description
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestone, _, err := client.CreateMilestone(owner, repo, opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("create %v/%v/milestone err: %v", owner, repo, err))
}
return to.TextResult(milestone)
}
func EditMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called EditMilestoneFn")
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"))
}
id, ok := req.GetArguments()["id"].(float64)
if !ok {
return to.ErrorResult(fmt.Errorf("id is required"))
}
opt := gitea_sdk.EditMilestoneOption{}
title, ok := req.GetArguments()["title"].(string)
if ok {
opt.Title = title
}
description, ok := req.GetArguments()["description"].(string)
if ok {
opt.Description = ptr.To(description)
}
state, ok := req.GetArguments()["state"].(string)
if ok {
opt.State = ptr.To(gitea_sdk.StateType(state))
}
client, err := gitea.ClientFromContext(ctx)
if err != nil {
return to.ErrorResult(fmt.Errorf("get gitea client err: %v", err))
}
milestone, _, err := client.EditMilestone(owner, repo, int64(id), opt)
if err != nil {
return to.ErrorResult(fmt.Errorf("edit %v/%v/milestone/%v err: %v", owner, repo, int64(id), err))
}
return to.TextResult(milestone)
}
func DeleteMilestoneFn(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
log.Debugf("Called DeleteMilestoneFn")
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"))
}
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.DeleteMilestone(owner, repo, int64(id))
if err != nil {
return to.ErrorResult(fmt.Errorf("delete %v/%v/milestone/%v err: %v", owner, repo, int64(id), err))
}
return to.TextResult("Milestone deleted successfully")
}

View File

@@ -12,6 +12,8 @@ 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"
@@ -31,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()...)
@@ -40,6 +45,9 @@ func RegisterTool(s *server.MCPServer) {
// Label Tool
s.AddTools(label.Tool.Tools()...)
// Milestone Tool
s.AddTools(milestone.Tool.Tools()...)
// Pull Tool
s.AddTools(pull.Tool.Tools()...)

View File

@@ -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
}

175
pkg/gitea/rest.go Normal file
View File

@@ -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
}

32
pkg/gitea/rest_test.go Normal file
View File

@@ -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")
}
})
}