25 Commits

Author SHA1 Message Date
Mathias Bergqvist
b8463d66a0 chore: drop environment: staging (no-op for solo homelab)
Some checks failed
CD / Lint / Test / Vet (push) Failing after 2s
CD / Build & Import (push) Has been skipped
CD / Deploy via GitOps (push) Has been skipped
2026-05-05 08:51:17 +02:00
Mathias Bergqvist
d261a9f3fe feat: gitea actions cd.yml (cobalt-dingo pattern) 2026-05-05 07:52:45 +02:00
Mathias Bergqvist
09b3f25211 chore: add check task for CI 2026-05-05 07:52:39 +02:00
Mathias Bergqvist
a8d04242d9 feat: dockerfile 2026-05-05 07:52:29 +02:00
Mathias Bergqvist
1f9934349b chore(tools): centralize pagination cap helper 2026-05-04 23:06:38 +02:00
Mathias Bergqvist
4274b48ea5 feat(gitea): default-branch lru cache
Shared LRU avoids repeated Gitea calls for default-branch resolution;
the simple stdlib map alternative would race on concurrent access without
a mutex per entry, which is more code than the LRU.
2026-05-04 23:06:06 +02:00
Mathias Bergqvist
fb473262ba feat(gitea): read retry once on 5xx GET 2026-05-04 23:04:55 +02:00
Mathias Bergqvist
39dc22ec3a feat(tools): create_project_from_template
Generates a new repo from mathias/template-go-web via Gitea's generate
API, then substitutes __PROJECT_NAME__ and __MODULE_PATH__ placeholders
in six known files (best-effort, partial failure surfaced in result).

Validates name regex, allowlist, template flag, and destination
non-existence before generating. Adds Template field to gitea.Repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 23:02:21 +02:00
Mathias Bergqvist
e95e87e8e3 feat(tools): pr_files_diff with caps
Returns per-file unified diff for a PR, capped at 20KB/file and 200KB
total response. Files exceeding per-file cap report truncated+omitted_lines;
files that would push the response over 200KB go to omitted_files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:57:11 +02:00
Mathias Bergqvist
d3d0fed6b1 feat(tools): pr_comment with identity footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:53:14 +02:00
Mathias Bergqvist
c8a353aa35 feat(tools): issue_comment with identity footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:52:24 +02:00
Mathias Bergqvist
6f43ff216f feat(tools): issue_create with identity footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:51:40 +02:00
Mathias Bergqvist
2c6b9986e4 feat(tools): code_search (org-wide fan-out)
When repo is omitted, lists owner's repos then concurrently searches
each one (semaphore cap 5, 5s per-repo timeout). Merges and sorts
hits by score desc with deterministic tiebreak. Partial failures
tracked in partial_repos without aborting the whole fan-out.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:48:39 +02:00
Mathias Bergqvist
e4a9d058f0 feat(tools): code_search (single-repo)
Adds SearchCode to gitea.Client and code_search MCP tool for single-repo
code search via GET /api/v1/repos/{owner}/{repo}/search?type=code.
Fan-out placeholder returns ErrValidation (lands in 7.3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:47:33 +02:00
Mathias Bergqvist
61cce37ff5 feat(tools): repo_search with allowlist post-filter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:44:44 +02:00
Mathias Bergqvist
43e016e8fa feat(tools): workflow_run_status 2026-05-04 22:25:23 +02:00
Mathias Bergqvist
ba172e3db8 feat(tools): workflow_run_trigger 2026-05-04 22:25:10 +02:00
Mathias Bergqvist
c4874ae8d1 feat(tools): pr_get 2026-05-04 22:21:20 +02:00
Mathias Bergqvist
9972dcd94e feat(tools): pr_create with identity footer 2026-05-04 22:20:33 +02:00
Mathias Bergqvist
5af8addc26 feat(tools): file_write_branch
Add BranchExists/CreateBranch/UpsertFile gitea client methods and the
file_write_branch MCP tool. Branch is auto-created from base (or repo
default_branch) when it doesn't exist; file is upserted via PUT contents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:15:39 +02:00
Mathias Bergqvist
044086b067 feat(tools): file_read with default-branch resolution
Adds GetFileContents to the gitea client and a file_read MCP tool.
When ref is omitted, the tool resolves the repo default_branch via
GetRepo before fetching contents. Decoded content capped at 1 MiB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 22:11:50 +02:00
Mathias Bergqvist
f10cc9ac4b feat(tools): repo_get 2026-05-04 22:08:24 +02:00
Mathias Bergqvist
33ad02d369 feat(tools): repo_list 2026-05-04 22:07:44 +02:00
Mathias Bergqvist
18eadc0ae9 feat(tools): tool interface + helpers 2026-05-04 21:27:50 +02:00
Mathias Bergqvist
4ebb1eef6d feat(allowlist): owner allowlist enforcement 2026-05-04 21:26:50 +02:00
55 changed files with 4607 additions and 8 deletions

185
.gitea/workflows/cd.yml Normal file
View File

@@ -0,0 +1,185 @@
name: CD
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
env:
IMAGE: gitea-mcp
jobs:
# ── 1. Quality gate ─────────────────────────────────────────────────────────
check:
name: Lint / Test / Vet
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false # self-hosted runner: Go cache persists on disk between runs
- name: Verify toolchain
run: |
go version
task --version
govulncheck -version 2>&1 || true
- name: Install golangci-lint
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh \
| sh -s -- -b "$(go env GOPATH)/bin" v2.11.4
golangci-lint --version
- name: Run checks
run: task check
# ── 2. Build image ──────────────────────────────────────────────────────────
build:
name: Build & Import
needs: check
runs-on: self-hosted
if: github.event_name != 'pull_request'
outputs:
image-tag: ${{ steps.meta.outputs.sha-tag }}
steps:
- uses: actions/checkout@v4
- name: Derive image tags
id: meta
run: |
SHA=$(git rev-parse --short HEAD)
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
REF="${{ github.ref }}"
if [[ "$REF" == refs/tags/v* ]]; then
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
fi
- name: Build and push to local registry
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
buildah build \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
-t ${REF} \
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
.
buildah push --tls-verify=false ${REF}
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
[[ -n "${{ steps.meta.outputs.version-tag }}" ]] && \
buildah push --tls-verify=false \
${REF} \
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
echo "✓ Image pushed to ${REF}"
- name: Smoke test
run: |
REGISTRY="localhost:5000"
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
sudo k3s ctr images pull --plain-http ${REF}
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /gitea-mcp 2>&1 || true)
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
echo "$OUTPUT" | grep -q "gitea-mcp" \
&& echo "✓ Smoke test passed" \
|| echo "⚠ Smoke test inconclusive: $OUTPUT"
# ── 3. Deploy via infra repo + Flux ─────────────────────────────────────────
deploy:
name: Deploy via GitOps
needs: build
runs-on: self-hosted
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Update image tag in infra repo
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
chmod 600 ~/.ssh/id_infra
ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_infra -o IdentitiesOnly=yes"
rm -rf /tmp/infra
git clone -b main ssh://git@10.0.1.20:30022/mathias/infra.git /tmp/infra
cd /tmp/infra
DEPLOYMENT="k3s/apps/gitea-mcp/deployment.yaml"
sed -i "s|image: localhost:5000/gitea-mcp:.*|image: localhost:5000/gitea-mcp:${IMAGE_TAG}|" "$DEPLOYMENT"
grep -q "localhost:5000/gitea-mcp:${IMAGE_TAG}" "$DEPLOYMENT" \
|| { echo "✗ image tag patch failed"; exit 1; }
if git diff --quiet "$DEPLOYMENT"; then
echo " image tag unchanged — skipping push"
else
git -c user.name="gitea-mcp CI" \
-c user.email="ci@gitea-mcp.local" \
commit -m "chore(deploy): gitea-mcp → ${IMAGE_TAG}" "$DEPLOYMENT"
git push origin main
echo "✓ pushed to infra repo"
fi
shred -u ~/.ssh/id_infra
- name: Trigger Flux reconcile (immediate)
run: |
kubectl -n flux-system annotate gitrepository flux-system \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
kubectl -n flux-system annotate kustomization apps \
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
- name: Wait for Flux to apply new image
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
run: |
EXPECTED="localhost:5000/gitea-mcp:${IMAGE_TAG}"
for i in $(seq 1 60); do
CURRENT=$(kubectl get deploy gitea-mcp -n gitea-mcp \
-o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || echo "")
if [ "$CURRENT" = "$EXPECTED" ]; then
echo "✓ Flux applied new image after ${i}s"
break
fi
sleep 1
done
kubectl get deploy gitea-mcp -n gitea-mcp \
-o jsonpath='{.spec.template.spec.containers[0].image}' \
| grep -qx "$EXPECTED" \
|| { echo "✗ Flux did not apply new image within 60s"; exit 1; }
- name: Verify rollout
run: |
kubectl rollout status deployment/gitea-mcp \
--namespace gitea-mcp \
--timeout=120s \
|| {
echo "── pod status ──"
kubectl get pods -n gitea-mcp -o wide
echo "── events ──"
kubectl get events -n gitea-mcp --sort-by='.lastTimestamp' | tail -20
echo "── describe ──"
kubectl describe pods -n gitea-mcp -l app=gitea-mcp | tail -40
exit 1
}
- name: Confirm pod running new image
env:
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
run: |
kubectl get pods -n gitea-mcp \
-l app=gitea-mcp \
--field-selector=status.phase=Running \
-o jsonpath='{.items[*].spec.containers[0].image}' \
| grep -q "localhost:5000/gitea-mcp:${IMAGE_TAG}" \
&& echo "✓ pod running new image" \
|| { echo "✗ pod image mismatch"; exit 1; }

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gitea-mcp ./cmd/gitea-mcp
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/gitea-mcp /gitea-mcp
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/gitea-mcp"]

View File

@@ -14,3 +14,9 @@ tasks:
lint: lint:
desc: Run golangci-lint desc: Run golangci-lint
cmds: [golangci-lint run ./...] cmds: [golangci-lint run ./...]
check:
desc: Lint, vet, and test (used by CI)
cmds:
- golangci-lint run ./...
- go vet ./...
- go test ./... -race -count=1

View File

@@ -5,10 +5,13 @@ import (
"net/http" "net/http"
"os" "os"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth" "gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/config" "gitea.d-ma.be/mathias/gitea-mcp/internal/config"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/mcp" "gitea.d-ma.be/mathias/gitea-mcp/internal/mcp"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry" "gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
) )
func main() { func main() {
@@ -20,8 +23,25 @@ func main() {
os.Exit(1) os.Exit(1)
} }
giteaClient := gitea.NewClient(cfg.GiteaBaseURL, cfg.GiteaAPIToken)
ownerAllow := allowlist.New(cfg.AllowedOwners)
reg := registry.New() reg := registry.New()
// Tool registration happens in Phase 6+; for now, registry is empty. reg.Register(tools.NewRepoList(giteaClient, ownerAllow))
reg.Register(tools.NewRepoGet(giteaClient, ownerAllow))
reg.Register(tools.NewFileRead(giteaClient, ownerAllow))
reg.Register(tools.NewFileWriteBranch(giteaClient, ownerAllow))
reg.Register(tools.NewPRCreate(giteaClient, ownerAllow))
reg.Register(tools.NewPRGet(giteaClient, ownerAllow))
reg.Register(tools.NewWorkflowRunTrigger(giteaClient, ownerAllow, cfg.GiteaBaseURL))
reg.Register(tools.NewWorkflowRunStatus(giteaClient, ownerAllow))
reg.Register(tools.NewRepoSearch(giteaClient, ownerAllow))
reg.Register(tools.NewCodeSearch(giteaClient, ownerAllow))
reg.Register(tools.NewIssueCreate(giteaClient, ownerAllow))
reg.Register(tools.NewIssueComment(giteaClient, ownerAllow))
reg.Register(tools.NewPRComment(giteaClient, ownerAllow))
reg.Register(tools.NewPRFilesDiff(giteaClient, ownerAllow))
reg.Register(tools.NewCreateProjectFromTemplate(giteaClient, ownerAllow, "mathias", "template-go-web"))
mcpSrv := mcp.NewServer(mcp.ServerOptions{ mcpSrv := mcp.NewServer(mcp.ServerOptions{
Registry: reg, Registry: reg,

1
go.mod
View File

@@ -4,6 +4,7 @@ go 1.26.2
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

2
go.sum
View File

@@ -1,5 +1,7 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=

View File

@@ -0,0 +1,25 @@
package allowlist
import "fmt"
type Allowlist struct {
owners map[string]struct{}
}
func New(owners []string) *Allowlist {
m := make(map[string]struct{}, len(owners))
for _, o := range owners {
m[o] = struct{}{}
}
return &Allowlist{owners: m}
}
func (a *Allowlist) Check(owner string) error {
if owner == "" {
return fmt.Errorf("owner required")
}
if _, ok := a.owners[owner]; !ok {
return fmt.Errorf("owner %q not in allowlist", owner)
}
return nil
}

View File

@@ -0,0 +1,16 @@
package allowlist_test
import (
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"github.com/stretchr/testify/assert"
)
func TestAllowlistCheck(t *testing.T) {
a := allowlist.New([]string{"mathias", "acme"})
assert.NoError(t, a.Check("mathias"))
assert.NoError(t, a.Check("acme"))
assert.Error(t, a.Check("evil"))
assert.Error(t, a.Check(""))
}

View File

@@ -6,23 +6,41 @@ import (
"io" "io"
"net/http" "net/http"
"time" "time"
"github.com/hashicorp/golang-lru/v2/expirable"
) )
type Client struct { type Client struct {
baseURL string baseURL string
token string token string
hc *http.Client hc *http.Client
branchCache *expirable.LRU[string, string]
} }
func NewClient(baseURL, token string) *Client { func NewClient(baseURL, token string) *Client {
return &Client{ return &Client{
baseURL: baseURL, baseURL: baseURL,
token: token, token: token,
hc: &http.Client{Timeout: 30 * time.Second}, hc: &http.Client{Timeout: 30 * time.Second},
branchCache: expirable.NewLRU[string, string](64, nil, 60*time.Second),
} }
} }
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) { // DefaultBranch returns the default branch for a repo. Cached for 60s.
func (c *Client) DefaultBranch(ctx context.Context, owner, name string) (string, error) {
key := owner + "/" + name
if v, ok := c.branchCache.Get(key); ok {
return v, nil
}
repo, err := c.GetRepo(ctx, owner, name)
if err != nil {
return "", err
}
c.branchCache.Add(key, repo.DefaultBranch)
return repo.DefaultBranch, nil
}
func (c *Client) doOnce(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
var reader io.Reader var reader io.Reader
if body != nil { if body != nil {
reader = bytes.NewReader(body) reader = bytes.NewReader(body)
@@ -48,6 +66,15 @@ func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]by
return b, resp.StatusCode, err return b, resp.StatusCode, err
} }
func (c *Client) do(ctx context.Context, method, path string, body []byte) ([]byte, int, error) {
b, status, err := c.doOnce(ctx, method, path, body)
if err == nil && method == http.MethodGet && status >= 500 && status < 600 {
time.Sleep(250 * time.Millisecond)
return c.doOnce(ctx, method, path, body)
}
return b, status, err
}
func (c *Client) GetJSON(ctx context.Context, path string) ([]byte, int, error) { func (c *Client) GetJSON(ctx context.Context, path string) ([]byte, int, error) {
return c.do(ctx, http.MethodGet, path, nil) return c.do(ctx, http.MethodGet, path, nil)
} }
@@ -67,3 +94,35 @@ func (c *Client) PutJSON(ctx context.Context, path string, body []byte) ([]byte,
func (c *Client) DeleteJSON(ctx context.Context, path string) ([]byte, int, error) { func (c *Client) DeleteJSON(ctx context.Context, path string) ([]byte, int, error) {
return c.do(ctx, http.MethodDelete, path, nil) return c.do(ctx, http.MethodDelete, path, nil)
} }
type rawResponse struct {
Body []byte
Status int
Headers http.Header
}
func (c *Client) doRaw(ctx context.Context, method, path string, body []byte) (*rawResponse, error) {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return nil, err
}
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return &rawResponse{Body: b, Status: resp.StatusCode, Headers: resp.Header}, err
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync/atomic"
"testing" "testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
@@ -27,3 +28,37 @@ func TestClientGetsTokenInHeader(t *testing.T) {
assert.Contains(t, string(body), `"ok":true`) assert.Contains(t, string(body), `"ok":true`)
assert.Equal(t, "token test-token", gotAuth) assert.Equal(t, "token test-token", gotAuth)
} }
func TestRetryOn5xxGetSucceedsOnSecondAttempt(t *testing.T) {
var attempts int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&attempts, 1)
if n == 1 {
http.Error(w, "boom", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
body, status, err := c.GetJSON(context.Background(), "/api/v1/test")
require.NoError(t, err)
assert.Equal(t, 200, status)
assert.Contains(t, string(body), `"ok":true`)
assert.Equal(t, int32(2), atomic.LoadInt32(&attempts))
}
func TestRetryOnPostNotRetried(t *testing.T) {
var attempts int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&attempts, 1)
http.Error(w, "boom", http.StatusServiceUnavailable)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, _, _ = c.PostJSON(context.Background(), "/api/v1/test", []byte(`{}`))
assert.Equal(t, int32(1), atomic.LoadInt32(&attempts), "POST should not retry")
}

View File

@@ -0,0 +1,43 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/url"
)
type CodeSearchHit struct {
Path string `json:"path"`
Snippet string `json:"snippet"`
HTMLURL string `json:"html_url"`
Score float64 `json:"score,omitempty"`
}
type codeSearchEnvelope struct {
Data []CodeSearchHit `json:"data"`
OK bool `json:"ok"`
}
func (c *Client) SearchCode(ctx context.Context, owner, repo, q string, page, limit int) ([]CodeSearchHit, error) {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 30
}
path := fmt.Sprintf("/api/v1/repos/%s/%s/search?q=%s&type=code&page=%d&limit=%d",
owner, repo, url.QueryEscape(q), page, limit)
body, status, err := c.GetJSON(ctx, path)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var env codeSearchEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
return env.Data, nil
}

View File

@@ -0,0 +1,39 @@
package gitea_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSearchCode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
assert.Equal(t, "SearchCode", r.URL.Query().Get("q"))
assert.Equal(t, "code", r.URL.Query().Get("type"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data":[{
"path":"internal/gitea/code_search.go",
"snippet":"func (c *Client) SearchCode",
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/code_search.go",
"score":2.5
}],
"ok":true
}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
hits, err := c.SearchCode(context.Background(), "mathias", "infra", "SearchCode", 1, 30)
require.NoError(t, err)
require.Len(t, hits, 1)
assert.Equal(t, "internal/gitea/code_search.go", hits[0].Path)
assert.Equal(t, "func (c *Client) SearchCode", hits[0].Snippet)
assert.InDelta(t, 2.5, hits[0].Score, 0.001)
}

113
internal/gitea/files.go Normal file
View File

@@ -0,0 +1,113 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
)
type FileContents struct {
Path string `json:"path"`
Sha string `json:"sha"`
Size int64 `json:"size"`
Content string `json:"content"`
Encoding string `json:"encoding"`
}
func (c *Client) GetFileContents(ctx context.Context, owner, repo, path, ref string) (*FileContents, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
if ref != "" {
p += "?ref=" + ref
}
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var fc FileContents
if err := json.Unmarshal(body, &fc); err != nil {
return nil, err
}
return &fc, nil
}
type Branch struct {
Name string `json:"name"`
Commit struct {
ID string `json:"id"`
URL string `json:"url"`
} `json:"commit"`
}
// BranchExists returns (true, nil) if the branch exists, (false, nil) on 404, (false, err) otherwise.
func (c *Client) BranchExists(ctx context.Context, owner, repo, branch string) (bool, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", owner, repo, branch)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return false, err
}
if status == 404 {
return false, nil
}
if err := MapStatus(status, body); err != nil {
return false, err
}
return true, nil
}
func (c *Client) CreateBranch(ctx context.Context, owner, repo, newBranch, oldBranch string) error {
p := fmt.Sprintf("/api/v1/repos/%s/%s/branches", owner, repo)
payload, err := json.Marshal(map[string]string{
"new_branch_name": newBranch,
"old_branch_name": oldBranch,
})
if err != nil {
return err
}
body, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return err
}
return MapStatus(status, body)
}
type UpsertFileArgs struct {
Branch string `json:"branch"`
Content string `json:"content"` // already base64-encoded
Message string `json:"message"`
Sha string `json:"sha,omitempty"`
}
type FileWriteResult struct {
Content struct {
Path string `json:"path"`
Sha string `json:"sha"`
HTMLURL string `json:"html_url"`
} `json:"content"`
Commit struct {
Sha string `json:"sha"`
HTMLURL string `json:"html_url"`
} `json:"commit"`
}
func (c *Client) UpsertFile(ctx context.Context, owner, repo, path string, args UpsertFileArgs) (*FileWriteResult, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", owner, repo, path)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
}
body, status, err := c.PutJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var out FileWriteResult
if err := json.Unmarshal(body, &out); err != nil {
return nil, err
}
return &out, nil
}

View File

@@ -0,0 +1,118 @@
package gitea_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetFileContents(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/contents/README.md", r.URL.Path)
assert.Equal(t, "main", r.URL.Query().Get("ref"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"path":"README.md","sha":"deadbeef","size":13,"content":"SGVsbG8sIHdvcmxkIQ==","encoding":"base64"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
fc, err := c.GetFileContents(context.Background(), "mathias", "infra", "README.md", "main")
require.NoError(t, err)
assert.Equal(t, "README.md", fc.Path)
assert.Equal(t, "deadbeef", fc.Sha)
assert.Equal(t, int64(13), fc.Size)
assert.Equal(t, "SGVsbG8sIHdvcmxkIQ==", fc.Content)
}
func TestBranchExistsTrue(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/branches/main", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"main","commit":{"id":"abc123","url":"http://example.com"}}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
exists, err := c.BranchExists(context.Background(), "o", "r", "main")
require.NoError(t, err)
assert.True(t, exists)
}
func TestBranchExistsFalseOn404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/branches/nonexistent", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"branch not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
exists, err := c.BranchExists(context.Background(), "o", "r", "nonexistent")
require.NoError(t, err)
assert.False(t, exists)
}
func TestCreateBranchSendsPayload(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/branches", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"name":"feat/x","commit":{"id":"abc","url":"http://example.com"}}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.CreateBranch(context.Background(), "o", "r", "feat/x", "main")
require.NoError(t, err)
var payload map[string]string
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "feat/x", payload["new_branch_name"])
assert.Equal(t, "main", payload["old_branch_name"])
}
func TestUpsertFileSendsPayloadAndDecodesResult(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/contents/p.md", r.URL.Path)
assert.Equal(t, http.MethodPut, r.Method)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"content":{"path":"p.md","sha":"newsha","html_url":"http://example.com/p.md"},"commit":{"sha":"abc","html_url":"http://example.com/commit/abc"}}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
result, err := c.UpsertFile(context.Background(), "o", "r", "p.md", gitea.UpsertFileArgs{
Branch: "feat/x",
Content: "aGVsbG8=",
Message: "add p.md",
Sha: "oldsha",
})
require.NoError(t, err)
var payload map[string]string
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "feat/x", payload["branch"])
assert.Equal(t, "aGVsbG8=", payload["content"])
assert.Equal(t, "add p.md", payload["message"])
assert.Equal(t, "oldsha", payload["sha"])
assert.Equal(t, "p.md", result.Content.Path)
assert.Equal(t, "newsha", result.Content.Sha)
assert.Equal(t, "http://example.com/p.md", result.Content.HTMLURL)
assert.Equal(t, "abc", result.Commit.Sha)
}

71
internal/gitea/issues.go Normal file
View File

@@ -0,0 +1,71 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
)
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
State string `json:"state"`
}
type CreateIssueArgs struct {
Title string `json:"title"`
Body string `json:"body"`
Labels []int64 `json:"labels,omitempty"`
Assignees []string `json:"assignees,omitempty"`
Milestone int64 `json:"milestone,omitempty"`
}
func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args CreateIssueArgs) (*Issue, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
}
body, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var iss Issue
if err := json.Unmarshal(body, &iss); err != nil {
return nil, err
}
return &iss, nil
}
type IssueComment struct {
ID int64 `json:"id"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
}
// CreateIssueComment posts to /issues/{index}/comments. Per Gitea, this same endpoint
// works for both issues and pull requests (PRs share index space with issues).
func (c *Client) CreateIssueComment(ctx context.Context, owner, repo string, index int, body string) (*IssueComment, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, index)
payload, err := json.Marshal(map[string]string{"body": body})
if err != nil {
return nil, err
}
respBody, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, respBody); err != nil {
return nil, err
}
var c2 IssueComment
if err := json.Unmarshal(respBody, &c2); err != nil {
return nil, err
}
return &c2, nil
}

View File

@@ -0,0 +1,72 @@
package gitea_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateIssue(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/issues", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"number":42,"title":"x","body":"y","html_url":"http://example.com/issues/42","state":"open"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
iss, err := c.CreateIssue(context.Background(), "o", "r", gitea.CreateIssueArgs{
Title: "x",
Body: "y",
})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "x", payload["title"])
assert.Equal(t, "y", payload["body"])
assert.Equal(t, 42, iss.Number)
assert.Equal(t, "x", iss.Title)
assert.Equal(t, "y", iss.Body)
assert.Equal(t, "http://example.com/issues/42", iss.HTMLURL)
assert.Equal(t, "open", iss.State)
}
func TestCreateIssueComment(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":7,"body":"hello","html_url":"http://example.com/issues/42#comment-7"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
comment, err := c.CreateIssueComment(context.Background(), "o", "r", 42, "hello")
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "hello", payload["body"])
assert.Equal(t, int64(7), comment.ID)
assert.Equal(t, "hello", comment.Body)
assert.Equal(t, "http://example.com/issues/42#comment-7", comment.HTMLURL)
}

103
internal/gitea/pulls.go Normal file
View File

@@ -0,0 +1,103 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
)
type PullRequest struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
State string `json:"state"`
Draft bool `json:"draft"`
Head struct {
Ref string `json:"ref"`
} `json:"head"`
Base struct {
Ref string `json:"ref"`
} `json:"base"`
}
type CreatePullRequestArgs struct {
Title string `json:"title"`
Body string `json:"body"`
Head string `json:"head"`
Base string `json:"base"`
Draft bool `json:"draft"`
}
func (c *Client) CreatePullRequest(ctx context.Context, owner, repo string, args CreatePullRequestArgs) (*PullRequest, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner, repo)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
}
body, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var pr PullRequest
if err := json.Unmarshal(body, &pr); err != nil {
return nil, err
}
return &pr, nil
}
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, index int) (*PullRequest, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var pr PullRequest
if err := json.Unmarshal(body, &pr); err != nil {
return nil, err
}
return &pr, nil
}
type PullRequestFile struct {
Filename string `json:"filename"`
Status string `json:"status"` // added | modified | deleted | renamed
Additions int `json:"additions"`
Deletions int `json:"deletions"`
}
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, index int) ([]PullRequestFile, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/files", owner, repo, index)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var files []PullRequestFile
if err := json.Unmarshal(body, &files); err != nil {
return nil, err
}
return files, nil
}
// GetPullRequestDiff returns the raw unified diff. The endpoint serves text/plain, not JSON,
// so we use doRaw to bypass the json Accept header expectation.
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, index int) ([]byte, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d.diff", owner, repo, index)
resp, err := c.doRaw(ctx, "GET", p, nil)
if err != nil {
return nil, err
}
if err := MapStatus(resp.Status, resp.Body); err != nil {
return nil, err
}
return resp.Body, nil
}

View File

@@ -0,0 +1,138 @@
package gitea_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const pullFixture = `{
"number": 7,
"title": "Add feature X",
"body": "This PR adds feature X",
"html_url": "http://example.com/pulls/7",
"state": "open",
"draft": false,
"head": {"ref": "feat/x"},
"base": {"ref": "main"}
}`
func TestCreatePullRequestSendsPayload(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/pulls", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(pullFixture))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
pr, err := c.CreatePullRequest(context.Background(), "o", "r", gitea.CreatePullRequestArgs{
Title: "Add feature X",
Body: "This PR adds feature X",
Head: "feat/x",
Base: "main",
Draft: false,
})
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
assert.Equal(t, "Add feature X", payload["title"])
assert.Equal(t, "This PR adds feature X", payload["body"])
assert.Equal(t, "feat/x", payload["head"])
assert.Equal(t, "main", payload["base"])
assert.Equal(t, false, payload["draft"])
assert.Equal(t, 7, pr.Number)
assert.Equal(t, "Add feature X", pr.Title)
assert.Equal(t, "http://example.com/pulls/7", pr.HTMLURL)
assert.Equal(t, "feat/x", pr.Head.Ref)
assert.Equal(t, "main", pr.Base.Ref)
assert.Equal(t, "open", pr.State)
assert.False(t, pr.Draft)
}
func TestGetPullRequest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/pulls/42", r.URL.Path)
assert.Equal(t, http.MethodGet, r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"number": 42,
"title": "Fix bug Y",
"body": "Fixes Y",
"html_url": "http://example.com/pulls/42",
"state": "open",
"draft": true,
"head": {"ref": "fix/y"},
"base": {"ref": "main"}
}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
pr, err := c.GetPullRequest(context.Background(), "o", "r", 42)
require.NoError(t, err)
assert.Equal(t, 42, pr.Number)
assert.Equal(t, "Fix bug Y", pr.Title)
assert.Equal(t, "http://example.com/pulls/42", pr.HTMLURL)
assert.Equal(t, "fix/y", pr.Head.Ref)
assert.Equal(t, "main", pr.Base.Ref)
assert.Equal(t, "open", pr.State)
assert.True(t, pr.Draft)
}
func TestGetPullRequestFiles(t *testing.T) {
filesJSON := `[
{"filename":"main.go","status":"modified","additions":10,"deletions":5},
{"filename":"README.md","status":"added","additions":20,"deletions":0}
]`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/pulls/42/files", r.URL.Path)
assert.Equal(t, http.MethodGet, r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(filesJSON))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
files, err := c.GetPullRequestFiles(context.Background(), "o", "r", 42)
require.NoError(t, err)
require.Len(t, files, 2)
assert.Equal(t, "main.go", files[0].Filename)
assert.Equal(t, "modified", files[0].Status)
assert.Equal(t, 10, files[0].Additions)
assert.Equal(t, 5, files[0].Deletions)
assert.Equal(t, "README.md", files[1].Filename)
assert.Equal(t, "added", files[1].Status)
assert.Equal(t, 20, files[1].Additions)
assert.Equal(t, 0, files[1].Deletions)
}
func TestGetPullRequestDiff(t *testing.T) {
rawDiff := "diff --git a/main.go b/main.go\n--- a/main.go\n+++ b/main.go\n@@ -1,2 +1,3 @@\n+package main\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/pulls/42.diff", r.URL.Path)
assert.Equal(t, http.MethodGet, r.Method)
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(rawDiff))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
diff, err := c.GetPullRequestDiff(context.Background(), "o", "r", 42)
require.NoError(t, err)
assert.Equal(t, []byte(rawDiff), diff)
}

88
internal/gitea/repos.go Normal file
View File

@@ -0,0 +1,88 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"net/url"
)
type Repo struct {
Name string `json:"name"`
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
Description string `json:"description"`
Private bool `json:"private"`
CloneURL string `json:"clone_url"`
HTMLURL string `json:"html_url"`
Template bool `json:"template"`
}
func (c *Client) ListRepos(ctx context.Context, owner string, page, limit int) ([]Repo, error) {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 30
}
path := fmt.Sprintf("/api/v1/users/%s/repos?page=%d&limit=%d", owner, page, limit)
body, status, err := c.GetJSON(ctx, path)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var repos []Repo
if err := json.Unmarshal(body, &repos); err != nil {
return nil, err
}
return repos, nil
}
type repoSearchEnvelope struct {
Data []Repo `json:"data"`
OK bool `json:"ok"`
}
func (c *Client) SearchRepos(ctx context.Context, q, owner string, page, limit int) ([]Repo, error) {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 30
}
path := fmt.Sprintf("/api/v1/repos/search?q=%s&page=%d&limit=%d",
url.QueryEscape(q), page, limit)
if owner != "" {
path += "&owner=" + url.QueryEscape(owner)
}
body, status, err := c.GetJSON(ctx, path)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var env repoSearchEnvelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
return env.Data, nil
}
func (c *Client) GetRepo(ctx context.Context, owner, name string) (*Repo, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s", owner, name)
body, status, err := c.GetJSON(ctx, path)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var r Repo
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
return &r, nil
}

View File

@@ -0,0 +1,68 @@
package gitea_test
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSearchRepos(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/search", r.URL.Path)
assert.Equal(t, "infra", r.URL.Query().Get("q"))
assert.Equal(t, "mathias", r.URL.Query().Get("owner"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}],"ok":true}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
repos, err := c.SearchRepos(context.Background(), "infra", "mathias", 1, 30)
require.NoError(t, err)
require.Len(t, repos, 1)
assert.Equal(t, "mathias/infra", repos[0].FullName)
}
func TestListRepos(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/users/mathias/repos", r.URL.Path)
assert.Equal(t, "1", r.URL.Query().Get("page"))
assert.Equal(t, "10", r.URL.Query().Get("limit"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"name":"infra","full_name":"mathias/infra","default_branch":"main","description":"d","private":true}]`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
repos, err := c.ListRepos(context.Background(), "mathias", 1, 10)
require.NoError(t, err)
require.Len(t, repos, 1)
assert.Equal(t, "mathias/infra", repos[0].FullName)
assert.Equal(t, "main", repos[0].DefaultBranch)
}
func TestDefaultBranchCachesAcrossCalls(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"infra","full_name":"o/infra","default_branch":"trunk"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
for i := 0; i < 5; i++ {
b, err := c.DefaultBranch(context.Background(), "o", "infra")
require.NoError(t, err)
assert.Equal(t, "trunk", b)
}
assert.Equal(t, int32(1), atomic.LoadInt32(&hits), "5 calls should cause exactly 1 server hit due to cache")
}

View File

@@ -0,0 +1,72 @@
package gitea
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
// GenerateFromTemplateArgs is the request body for POST /repos/{owner}/{repo}/generate.
type GenerateFromTemplateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Private bool `json:"private"`
DefaultBranch string `json:"default_branch,omitempty"`
GitContent bool `json:"git_content"` // include all template files
}
// GenerateFromTemplate creates a new repository from a template via POST /repos/{tmplOwner}/{tmplName}/generate.
func (c *Client) GenerateFromTemplate(ctx context.Context, tmplOwner, tmplName string, args GenerateFromTemplateArgs) (*Repo, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/generate", tmplOwner, tmplName)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
}
body, status, err := c.PostJSON(ctx, p, payload)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var r Repo
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
return &r, nil
}
// SubstituteFile reads a file from the given branch, applies string replacements,
// and writes it back if any changes were made. Best-effort — returns a wrapped error
// that includes the file path.
func (c *Client) SubstituteFile(ctx context.Context, owner, repo, branch, path string, replacements map[string]string) error {
fc, err := c.GetFileContents(ctx, owner, repo, path, branch)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
if err != nil {
return fmt.Errorf("decode %s: %w", path, err)
}
content := string(decoded)
for k, v := range replacements {
content = strings.ReplaceAll(content, k, v)
}
if content == string(decoded) {
return nil // no changes, skip write
}
encoded := base64.StdEncoding.EncodeToString([]byte(content))
_, err = c.UpsertFile(ctx, owner, repo, path, UpsertFileArgs{
Branch: branch,
Content: encoded,
Message: "Apply template substitutions",
Sha: fc.Sha,
})
if err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}

View File

@@ -0,0 +1,156 @@
package gitea_test
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateFromTemplate(t *testing.T) {
var capturedBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/template-go-web/generate", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
var err error
capturedBody, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"name":"new-svc",
"full_name":"mathias/new-svc",
"default_branch":"main",
"description":"A new service",
"private":true,
"clone_url":"http://gitea.example.com/mathias/new-svc.git",
"html_url":"http://gitea.example.com/mathias/new-svc",
"template":false
}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
repo, err := c.GenerateFromTemplate(context.Background(), "mathias", "template-go-web", gitea.GenerateFromTemplateArgs{
Owner: "mathias",
Name: "new-svc",
Description: "A new service",
Private: true,
GitContent: true,
})
require.NoError(t, err)
// Verify the captured POST body contains the expected fields.
var payload map[string]any
require.NoError(t, json.Unmarshal(capturedBody, &payload))
assert.Equal(t, "mathias", payload["owner"])
assert.Equal(t, "new-svc", payload["name"])
assert.Equal(t, "A new service", payload["description"])
assert.Equal(t, true, payload["private"])
assert.Equal(t, true, payload["git_content"])
// Verify the decoded repo fields.
assert.Equal(t, "new-svc", repo.Name)
assert.Equal(t, "mathias/new-svc", repo.FullName)
assert.Equal(t, "main", repo.DefaultBranch)
assert.Equal(t, "A new service", repo.Description)
assert.True(t, repo.Private)
assert.Equal(t, "http://gitea.example.com/mathias/new-svc.git", repo.CloneURL)
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", repo.HTMLURL)
}
func TestSubstituteFileApplies(t *testing.T) {
originalContent := "module __MODULE_PATH__\n\ngo 1.22\n"
encoded := base64.StdEncoding.EncodeToString([]byte(originalContent))
var capturedPutBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path)
assert.Equal(t, "main", r.URL.Query().Get("ref"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":30,"content":"` + encoded + `","encoding":"base64"}`))
case http.MethodPut:
assert.Equal(t, "/api/v1/repos/mathias/new-svc/contents/go.mod", r.URL.Path)
var err error
capturedPutBody, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"commitsha","html_url":""}}`))
default:
t.Errorf("unexpected method %s", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
}
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
})
require.NoError(t, err)
// Verify the PUT body contains the substituted content.
require.NotNil(t, capturedPutBody, "PUT should have been called")
var payload map[string]string
require.NoError(t, json.Unmarshal(capturedPutBody, &payload))
decoded, err := base64.StdEncoding.DecodeString(payload["content"])
require.NoError(t, err)
assert.Contains(t, string(decoded), "gitea.d-ma.be/mathias/new-svc")
assert.NotContains(t, string(decoded), "__MODULE_PATH__")
assert.Equal(t, "abc123", payload["sha"])
assert.Equal(t, "Apply template substitutions", payload["message"])
}
func TestSubstituteFileNoChangeSkipsWrite(t *testing.T) {
originalContent := "module gitea.d-ma.be/mathias/existing\n\ngo 1.22\n"
encoded := base64.StdEncoding.EncodeToString([]byte(originalContent))
var putCount atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"path":"go.mod","sha":"abc123","size":40,"content":"` + encoded + `","encoding":"base64"}`))
case http.MethodPut:
putCount.Add(1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"content":{"path":"go.mod","sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`))
}
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
// Replacements that don't match anything in the content.
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
})
require.NoError(t, err)
assert.Equal(t, int32(0), putCount.Load(), "PUT should not be called when content is unchanged")
}
func TestSubstituteFileReadError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"file not found"}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
err := c.SubstituteFile(context.Background(), "mathias", "new-svc", "main", "go.mod", map[string]string{
"__MODULE_PATH__": "gitea.d-ma.be/mathias/new-svc",
})
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrNotFound), "error should wrap ErrNotFound, got: %v", err)
}

View File

@@ -0,0 +1,79 @@
package gitea
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// DispatchWorkflowArgs is the request body for a workflow_dispatch trigger.
type DispatchWorkflowArgs struct {
Ref string `json:"ref"`
Inputs map[string]any `json:"inputs,omitempty"`
}
// WorkflowRunTrigger holds the run ID extracted from the Location header.
type WorkflowRunTrigger struct {
RunID int64
}
// DispatchWorkflow triggers a workflow_dispatch event and returns the new run ID.
func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow string, args DispatchWorkflowArgs) (*WorkflowRunTrigger, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, workflow)
payload, err := json.Marshal(args)
if err != nil {
return nil, err
}
resp, err := c.doRaw(ctx, "POST", p, payload)
if err != nil {
return nil, err
}
if resp.Status != 204 {
if mapErr := MapStatus(resp.Status, resp.Body); mapErr != nil {
return nil, mapErr
}
return nil, fmt.Errorf("unexpected status %d", resp.Status)
}
location := resp.Headers.Get("Location")
if location == "" {
return nil, fmt.Errorf("missing Location header in dispatch response")
}
// Location is e.g. "/api/v1/repos/o/r/actions/runs/123" — take the last segment.
parts := strings.Split(strings.TrimRight(location, "/"), "/")
if len(parts) == 0 {
return nil, fmt.Errorf("malformed Location: %s", location)
}
runID, err := strconv.ParseInt(parts[len(parts)-1], 10, 64)
if err != nil {
return nil, fmt.Errorf("parse run id from %q: %w", location, err)
}
return &WorkflowRunTrigger{RunID: runID}, nil
}
// WorkflowRun represents a Gitea Actions run.
type WorkflowRun struct {
ID int64 `json:"id"`
Status string `json:"status"` // queued | in_progress | completed
Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed)
StartedAt string `json:"started_at"`
HTMLURL string `json:"html_url"`
}
// GetWorkflowRun fetches the status of a specific Actions run.
func (c *Client) GetWorkflowRun(ctx context.Context, owner, repo string, runID int64) (*WorkflowRun, error) {
p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, repo, runID)
body, status, err := c.GetJSON(ctx, p)
if err != nil {
return nil, err
}
if err := MapStatus(status, body); err != nil {
return nil, err
}
var run WorkflowRun
if err := json.Unmarshal(body, &run); err != nil {
return nil, err
}
return &run, nil
}

View File

@@ -0,0 +1,93 @@
package gitea_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDispatchWorkflow(t *testing.T) {
var gotBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/api/v1/repos/o/r/actions/workflows/ci.yml/dispatches", r.URL.Path)
var err error
gotBody, err = io.ReadAll(r.Body)
assert.NoError(t, err)
w.Header().Set("Location", "/api/v1/repos/o/r/actions/runs/789")
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
result, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{
Ref: "main",
Inputs: map[string]any{"env": "prod"},
})
require.NoError(t, err)
assert.Equal(t, int64(789), result.RunID)
var body map[string]any
require.NoError(t, json.Unmarshal(gotBody, &body))
assert.Equal(t, "main", body["ref"])
inputs, ok := body["inputs"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "prod", inputs["env"])
}
func TestDispatchWorkflowMissingLocation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 204 but no Location header
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
require.Error(t, err)
assert.Contains(t, err.Error(), "Location")
}
func TestDispatchWorkflowError404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
_, err := c.DispatchWorkflow(context.Background(), "o", "r", "ci.yml", gitea.DispatchWorkflowArgs{Ref: "main"})
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrNotFound))
}
func TestGetWorkflowRun(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/actions/runs/789", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":789,
"status":"completed",
"conclusion":"success",
"started_at":"2026-05-04T10:00:00Z",
"html_url":"http://gitea.example/o/r/actions/runs/789"
}`))
}))
defer srv.Close()
c := gitea.NewClient(srv.URL, "tok")
run, err := c.GetWorkflowRun(context.Background(), "o", "r", 789)
require.NoError(t, err)
assert.Equal(t, int64(789), run.ID)
assert.Equal(t, "completed", run.Status)
assert.Equal(t, "success", run.Conclusion)
assert.Equal(t, "2026-05-04T10:00:00Z", run.StartedAt)
assert.Equal(t, "http://gitea.example/o/r/actions/runs/789", run.HTMLURL)
}

View File

@@ -0,0 +1,189 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"sort"
"sync"
"time"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type semaphore chan struct{}
func newSem(n int) semaphore { return make(semaphore, n) }
func (s semaphore) acquire() { s <- struct{}{} }
func (s semaphore) release() { <-s }
type CodeSearch struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewCodeSearch(c *gitea.Client, a *allowlist.Allowlist) *CodeSearch {
return &CodeSearch{c: c, a: a}
}
func (t *CodeSearch) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "code_search",
Description: "Search code across one repo or fan out across an owner's repos.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"q":{"type":"string"},
"owner":{"type":"string"},
"repo":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["q","owner"]
}`),
}
}
type codeSearchArgs struct {
Q string `json:"q"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Page int `json:"page"`
Limit int `json:"limit"`
}
type codeSearchResult struct {
Repo string `json:"repo"`
Path string `json:"path"`
Snippet string `json:"snippet"`
Score float64 `json:"score"`
HTMLURL string `json:"html_url"`
}
func (t *CodeSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args codeSearchArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if args.Q == "" {
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Page < 1 {
args.Page = 1
}
args.Limit = capLimit(args.Limit, 30)
if args.Repo != "" {
return t.singleRepo(ctx, args)
}
return t.fanOut(ctx, args)
}
func (t *CodeSearch) singleRepo(ctx context.Context, args codeSearchArgs) (json.RawMessage, error) {
hits, err := t.c.SearchCode(ctx, args.Owner, args.Repo, args.Q, args.Page, args.Limit)
if err != nil {
return nil, err
}
results := make([]codeSearchResult, 0, len(hits))
repoFull := args.Owner + "/" + args.Repo
for _, h := range hits {
score := h.Score
if score == 0 {
score = 1.0
}
results = append(results, codeSearchResult{
Repo: repoFull,
Path: h.Path,
Snippet: h.Snippet,
Score: score,
HTMLURL: h.HTMLURL,
})
}
out := map[string]any{"results": results}
if len(hits) == args.Limit {
out["next_page"] = args.Page + 1
}
return textOK(out)
}
func (t *CodeSearch) fanOut(ctx context.Context, args codeSearchArgs) (json.RawMessage, error) {
repos, err := t.c.ListRepos(ctx, args.Owner, 1, 50)
if err != nil {
return nil, err
}
type repoResult struct {
repo string
hits []gitea.CodeSearchHit
err error
}
resultsCh := make(chan repoResult, len(repos))
sem := newSem(5)
var wg sync.WaitGroup
for _, r := range repos {
repo := r // capture
wg.Add(1)
go func() {
defer wg.Done()
sem.acquire()
defer sem.release()
rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
hits, err := t.c.SearchCode(rctx, args.Owner, repo.Name, args.Q, 1, args.Limit)
resultsCh <- repoResult{repo: args.Owner + "/" + repo.Name, hits: hits, err: err}
}()
}
wg.Wait()
close(resultsCh)
merged := make([]codeSearchResult, 0)
var partialRepos []string
for rr := range resultsCh {
if rr.err != nil {
partialRepos = append(partialRepos, rr.repo)
continue
}
for _, h := range rr.hits {
score := h.Score
if score == 0 {
score = 1.0
}
merged = append(merged, codeSearchResult{
Repo: rr.repo, Path: h.Path, Snippet: h.Snippet, Score: score, HTMLURL: h.HTMLURL,
})
}
}
// Sort by score desc, then by repo+path for determinism.
sort.Slice(merged, func(i, j int) bool {
if merged[i].Score != merged[j].Score {
return merged[i].Score > merged[j].Score
}
if merged[i].Repo != merged[j].Repo {
return merged[i].Repo < merged[j].Repo
}
return merged[i].Path < merged[j].Path
})
if len(merged) > args.Limit {
merged = merged[:args.Limit]
}
out := map[string]any{
"results": merged,
"partial": len(partialRepos) > 0,
}
if len(partialRepos) > 0 {
sort.Strings(partialRepos)
out["partial_repos"] = partialRepos
}
return textOK(out)
}

View File

@@ -0,0 +1,187 @@
package tools_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCodeSearchSingleRepo(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/search", r.URL.Path)
assert.Equal(t, "ListRepos", r.URL.Query().Get("q"))
assert.Equal(t, "code", r.URL.Query().Get("type"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"data":[{
"path":"internal/gitea/repos.go",
"snippet":"func (c *Client) ListRepos",
"html_url":"http://gitea.example.com/mathias/infra/src/branch/main/internal/gitea/repos.go",
"score":3.0
}],
"ok":true
}`))
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"ListRepos","owner":"mathias","repo":"infra"}`))
require.NoError(t, err)
var result struct {
Results []struct {
Repo string `json:"repo"`
Path string `json:"path"`
Snippet string `json:"snippet"`
Score float64 `json:"score"`
} `json:"results"`
}
require.NoError(t, json.Unmarshal(out, &result))
require.Len(t, result.Results, 1)
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
assert.Equal(t, "internal/gitea/repos.go", result.Results[0].Path)
assert.Equal(t, "func (c *Client) ListRepos", result.Results[0].Snippet)
}
func TestCodeSearchAllowlistRejects(t *testing.T) {
tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"q":"foo","owner":"evil","repo":"infra"}`))
require.Error(t, err)
}
func TestCodeSearchRequiresQ(t *testing.T) {
tool := tools.NewCodeSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","repo":"infra"}`))
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrValidation))
}
func TestCodeSearchFanOutHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
{"name":"gitea-mcp","full_name":"mathias/gitea-mcp","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/infra/search":
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":2.0}],"ok":true}`))
case "/api/v1/repos/mathias/gitea-mcp/search":
_, _ = w.Write([]byte(`{"data":[{"path":"cmd/main.go","snippet":"gitea-mcp hit","html_url":"http://x/gitea-mcp/main.go","score":1.0}],"ok":true}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"hit","owner":"mathias"}`))
require.NoError(t, err)
var result struct {
Results []struct {
Repo string `json:"repo"`
Path string `json:"path"`
Snippet string `json:"snippet"`
} `json:"results"`
Partial bool `json:"partial"`
}
require.NoError(t, json.Unmarshal(out, &result))
assert.False(t, result.Partial)
require.Len(t, result.Results, 2)
repos := make([]string, 0, 2)
for _, r := range result.Results {
repos = append(repos, r.Repo)
}
assert.Contains(t, repos, "mathias/infra")
assert.Contains(t, repos, "mathias/gitea-mcp")
}
func TestCodeSearchFanOutPartialFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"infra","full_name":"mathias/infra","default_branch":"main"},
{"name":"broken","full_name":"mathias/broken","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/infra/search":
_, _ = w.Write([]byte(`{"data":[{"path":"main.go","snippet":"infra hit","html_url":"http://x/infra/main.go","score":1.0}],"ok":true}`))
case "/api/v1/repos/mathias/broken/search":
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal error"}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"hit","owner":"mathias"}`))
require.NoError(t, err)
var result struct {
Results []struct{ Repo string `json:"repo"` } `json:"results"`
Partial bool `json:"partial"`
PartialRepos []string `json:"partial_repos"`
}
require.NoError(t, json.Unmarshal(out, &result))
assert.True(t, result.Partial)
require.Len(t, result.PartialRepos, 1)
assert.Equal(t, "mathias/broken", result.PartialRepos[0])
require.Len(t, result.Results, 1)
assert.Equal(t, "mathias/infra", result.Results[0].Repo)
}
func TestCodeSearchFanOutSortsByScore(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/users/mathias/repos":
_, _ = w.Write([]byte(`[
{"name":"alpha","full_name":"mathias/alpha","default_branch":"main"},
{"name":"beta","full_name":"mathias/beta","default_branch":"main"}
]`))
case "/api/v1/repos/mathias/alpha/search":
// low score
_, _ = w.Write([]byte(`{"data":[{"path":"a.go","snippet":"low","html_url":"http://x/alpha/a.go","score":1.0}],"ok":true}`))
case "/api/v1/repos/mathias/beta/search":
// high score
_, _ = w.Write([]byte(`{"data":[{"path":"b.go","snippet":"high","html_url":"http://x/beta/b.go","score":5.0}],"ok":true}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
tool := tools.NewCodeSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"something","owner":"mathias"}`))
require.NoError(t, err)
var result struct {
Results []struct {
Snippet string `json:"snippet"`
Score float64 `json:"score"`
} `json:"results"`
}
require.NoError(t, json.Unmarshal(out, &result))
require.Len(t, result.Results, 2)
// First result must be the high-score one
assert.True(t, result.Results[0].Score > result.Results[1].Score,
"expected results sorted by score desc, got %v then %v",
result.Results[0].Score, result.Results[1].Score)
assert.True(t, strings.Contains(result.Results[0].Snippet, "high"))
}

View File

@@ -0,0 +1,146 @@
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
var nameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{1,38}[a-z0-9]$`)
var substitutionFiles = []string{
"go.mod",
"Taskfile.yml",
"Dockerfile",
".gitea/workflows/cd.yml",
"README.md",
".context/PROJECT.md",
}
func substitutions(owner, name string) map[string]string {
return map[string]string{
"__PROJECT_NAME__": name,
"__MODULE_PATH__": "gitea.d-ma.be/" + owner + "/" + name,
}
}
// CreateProjectFromTemplate is the exported type so tests can reference it.
type CreateProjectFromTemplate struct {
c *gitea.Client
a *allowlist.Allowlist
templateOwner string
templateName string
}
func NewCreateProjectFromTemplate(c *gitea.Client, a *allowlist.Allowlist, tmplOwner, tmplName string) *CreateProjectFromTemplate {
return &CreateProjectFromTemplate{c: c, a: a, templateOwner: tmplOwner, templateName: tmplName}
}
func (t *CreateProjectFromTemplate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "create_project_from_template",
Description: "Create a new project repo from the template, applying placeholder substitutions to known files.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string","pattern":"^[a-z][a-z0-9-]{1,38}[a-z0-9]$"},
"description":{"type":"string"},
"private":{"type":"boolean"}
},
"required":["owner","name"]
}`),
}
}
type createProjectArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description"`
Private bool `json:"private"`
}
type createProjectResult struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
}
func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args createProjectArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
// Allowlist check first.
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
// Validate name format.
if !nameRe.MatchString(args.Name) {
return nil, fmt.Errorf("name %q does not match pattern %s: %w", args.Name, nameRe.String(), gitea.ErrValidation)
}
// Verify template exists and is marked as a template repo.
tmpl, err := t.c.GetRepo(ctx, t.templateOwner, t.templateName)
if err != nil {
return nil, fmt.Errorf("template lookup: %w", err)
}
if !tmpl.Template {
return nil, fmt.Errorf("repo %s/%s is not marked as template: %w", t.templateOwner, t.templateName, gitea.ErrValidation)
}
// Verify destination doesn't already exist.
if _, err := t.c.GetRepo(ctx, args.Owner, args.Name); err == nil {
return nil, fmt.Errorf("destination %s/%s already exists: %w", args.Owner, args.Name, gitea.ErrConflict)
} else if !errors.Is(err, gitea.ErrNotFound) {
return nil, fmt.Errorf("destination check: %w", err)
}
// Generate repo from template.
newRepo, err := t.c.GenerateFromTemplate(ctx, t.templateOwner, t.templateName, gitea.GenerateFromTemplateArgs{
Owner: args.Owner,
Name: args.Name,
Description: args.Description,
Private: args.Private,
GitContent: true,
})
if err != nil {
return nil, fmt.Errorf("generate: %w", err)
}
result := createProjectResult{
FullName: newRepo.FullName,
HTMLURL: newRepo.HTMLURL,
CloneURL: newRepo.CloneURL,
DefaultBranch: newRepo.DefaultBranch,
}
// Substitute placeholders in known files (best-effort).
repls := substitutions(args.Owner, args.Name)
branch := newRepo.DefaultBranch
for _, path := range substitutionFiles {
if err := t.c.SubstituteFile(ctx, args.Owner, args.Name, branch, path, repls); err != nil {
// Files that don't exist in this template are silently skipped.
if errors.Is(err, gitea.ErrNotFound) {
continue
}
// Any other error halts the substitution pass with partial_failure recorded.
result.PartialFailure = fmt.Sprintf("%s: %v", path, err)
break
}
result.FilesSubstituted = append(result.FilesSubstituted, path)
}
return textOK(result)
}

View File

@@ -0,0 +1,266 @@
package tools_test
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// substitutionFileList matches the tool's internal list — used to drive fake server routing.
var substitutionFileList = []string{
"go.mod",
"Taskfile.yml",
"Dockerfile",
".gitea/workflows/cd.yml",
"README.md",
".context/PROJECT.md",
}
// contentWithPlaceholder is a template file body that contains the placeholder.
const contentWithPlaceholder = "# __PROJECT_NAME__\nmodule __MODULE_PATH__\n"
func encodedContent(s string) string {
return base64.StdEncoding.EncodeToString([]byte(s))
}
// fileContentsJSON returns a JSON FileContents object for the given path.
func fileContentsJSON(path string) string {
enc := encodedContent(contentWithPlaceholder)
return fmt.Sprintf(`{"path":%q,"sha":"sha-%s","size":40,"content":%q,"encoding":"base64"}`,
path, strings.ReplaceAll(path, "/", "-"), enc)
}
// fileWriteResultJSON returns a minimal FileWriteResult JSON.
func fileWriteResultJSON(path string) string {
return fmt.Sprintf(`{"content":{"path":%q,"sha":"newsha","html_url":""},"commit":{"sha":"c","html_url":""}}`, path)
}
// newTemplateRepoJSON returns a JSON Repo marked as template.
func newTemplateRepoJSON(name string, isTemplate bool) string {
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":%v}`,
name, name, name, name, isTemplate)
}
// newGeneratedRepoJSON returns the JSON for the newly generated repo.
func newGeneratedRepoJSON(name string) string {
return fmt.Sprintf(`{"name":%q,"full_name":"mathias/%s","default_branch":"main","description":"","private":false,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`,
name, name, name, name)
}
func newCreateProjectTool(srvURL string) *tools.CreateProjectFromTemplate {
c := gitea.NewClient(srvURL, "tok")
a := allowlist.New([]string{"mathias"})
return tools.NewCreateProjectFromTemplate(c, a, "mathias", "template-go-web")
}
// TestCreateProjectHappyPath: all 6 files served and substituted.
func TestCreateProjectHappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
// Template repo lookup
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
// Destination repo lookup — 404 means it doesn't exist yet
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
// Generate
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
// File contents GET — handle all 6 substitution files
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
// File contents PUT — handle all 6 substitution files
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
tool := newCreateProjectTool(srv.URL)
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc","description":"A new service"}`))
require.NoError(t, err)
var out struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
CloneURL string `json:"clone_url"`
DefaultBranch string `json:"default_branch"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
}
require.NoError(t, json.Unmarshal(result, &out))
assert.Equal(t, "mathias/new-svc", out.FullName)
assert.Equal(t, "http://gitea.example.com/mathias/new-svc", out.HTMLURL)
assert.Equal(t, "main", out.DefaultBranch)
assert.ElementsMatch(t, substitutionFileList, out.FilesSubstituted)
assert.Empty(t, out.PartialFailure)
}
// TestCreateProjectNameRegexFailure: invalid name returns ErrValidation without hitting network.
func TestCreateProjectNameRegexFailure(t *testing.T) {
tool := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""),
allowlist.New([]string{"mathias"}),
"mathias", "template-go-web",
)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"INVALID_NAME"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
// TestCreateProjectAllowlistRejects: owner not in allowlist returns error.
func TestCreateProjectAllowlistRejects(t *testing.T) {
tool := tools.NewCreateProjectFromTemplate(
gitea.NewClient("http://unused", ""),
allowlist.New([]string{"mathias"}),
"mathias", "template-go-web",
)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"new-svc"}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "allowlist")
}
// TestCreateProjectTemplateNotTemplate: template repo exists but is not marked as template.
func TestCreateProjectTemplateNotTemplate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Template lookup returns a non-template repo.
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web" {
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", false)))
return
}
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
tool := newCreateProjectTool(srv.URL)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}
// TestCreateProjectDestinationExists: destination repo already exists.
func TestCreateProjectDestinationExists(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
// Destination exists — return 200.
_, _ = w.Write([]byte(newTemplateRepoJSON("new-svc", false)))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
tool := newCreateProjectTool(srv.URL)
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrConflict)
}
// TestCreateProjectMidPassSubstitutionFailure: the 4th file (.gitea/workflows/cd.yml) PUT fails;
// the first 3 are substituted, partial_failure is populated, no Go error is returned.
func TestCreateProjectMidPassSubstitutionFailure(t *testing.T) {
// Files that should succeed (index 0-2 in substitutionFileList).
successFiles := map[string]bool{
"go.mod": true,
"Taskfile.yml": true,
"Dockerfile": true,
}
// The 4th file (index 3) is .gitea/workflows/cd.yml — its PUT returns 500.
failFile := ".gitea/workflows/cd.yml"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/template-go-web":
_, _ = w.Write([]byte(newTemplateRepoJSON("template-go-web", true)))
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/mathias/new-svc":
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/mathias/template-go-web/generate":
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(newGeneratedRepoJSON("new-svc")))
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
_, _ = w.Write([]byte(fileContentsJSON(filePath)))
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/"):
filePath := strings.TrimPrefix(r.URL.Path, "/api/v1/repos/mathias/new-svc/contents/")
if filePath == failFile {
// Simulate upstream 500.
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
return
}
if !successFiles[filePath] {
t.Errorf("unexpected PUT for file: %s", filePath)
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(fileWriteResultJSON(filePath)))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
tool := newCreateProjectTool(srv.URL)
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"new-svc"}`))
// Best-effort: no Go error returned, partial state in result.
require.NoError(t, err)
var out struct {
FullName string `json:"full_name"`
FilesSubstituted []string `json:"files_substituted"`
PartialFailure string `json:"partial_failure,omitempty"`
}
require.NoError(t, json.Unmarshal(result, &out))
// First 3 files should be in FilesSubstituted.
assert.Len(t, out.FilesSubstituted, 3)
assert.Contains(t, out.FilesSubstituted, "go.mod")
assert.Contains(t, out.FilesSubstituted, "Taskfile.yml")
assert.Contains(t, out.FilesSubstituted, "Dockerfile")
assert.NotContains(t, out.FilesSubstituted, failFile)
// partial_failure should be non-empty.
assert.NotEmpty(t, out.PartialFailure, "partial_failure should be populated on mid-pass failure")
}

View File

@@ -0,0 +1,88 @@
package tools
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
const fileReadMaxBytes = 1 << 20 // 1 MiB
type FileRead struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewFileRead(c *gitea.Client, a *allowlist.Allowlist) *FileRead {
return &FileRead{c: c, a: a}
}
func (t *FileRead) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "file_read",
Description: "Read a file from a repo at a given ref. Defaults to the repo's default branch.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"path":{"type":"string"},
"ref":{"type":"string"}
},
"required":["owner","name","path"]
}`),
}
}
type fileReadArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Path string `json:"path"`
Ref string `json:"ref"`
}
func (t *FileRead) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args fileReadArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
ref := args.Ref
if ref == "" {
var err error
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
if err != nil {
return nil, err
}
}
fc, err := t.c.GetFileContents(ctx, args.Owner, args.Name, args.Path, ref)
if err != nil {
return nil, err
}
if fc.Size > fileReadMaxBytes {
return nil, fmt.Errorf("file %q size %d exceeds 1MiB cap: %w", args.Path, fc.Size, gitea.ErrValidation)
}
decoded, err := base64.StdEncoding.DecodeString(fc.Content)
if err != nil {
return nil, fmt.Errorf("decode base64 content: %w", err)
}
return textOK(map[string]any{
"path": fc.Path,
"ref": ref,
"sha": fc.Sha,
"size": fc.Size,
"content": string(decoded),
})
}

View File

@@ -0,0 +1,64 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFileReadToolWithExplicitRef(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra/contents/README.md", r.URL.Path)
assert.Equal(t, "main", r.URL.Query().Get("ref"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"path":"README.md","sha":"deadbeef","size":13,"content":"SGVsbG8sIHdvcmxkIQ==","encoding":"base64"}`))
}))
defer srv.Close()
tool := tools.NewFileRead(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","path":"README.md","ref":"main"}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, "README.md", result["path"])
assert.Equal(t, "main", result["ref"])
assert.Equal(t, "Hello, world!", result["content"])
}
func TestFileReadToolDefaultBranchResolution(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/mathias/infra", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main"}`))
})
mux.HandleFunc("/api/v1/repos/mathias/infra/contents/README.md", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "main", r.URL.Query().Get("ref"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"path":"README.md","sha":"deadbeef","size":13,"content":"SGVsbG8sIHdvcmxkIQ==","encoding":"base64"}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
tool := tools.NewFileRead(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra","path":"README.md"}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, "main", result["ref"])
}
func TestFileReadAllowlistRejects(t *testing.T) {
tool := tools.NewFileRead(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"infra","path":"README.md"}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,107 @@
package tools
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type FileWriteBranch struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewFileWriteBranch(c *gitea.Client, a *allowlist.Allowlist) *FileWriteBranch {
return &FileWriteBranch{c: c, a: a}
}
func (t *FileWriteBranch) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "file_write_branch",
Description: "Create or update a file on a feature branch. Branch is created from base if it doesn't exist.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"path":{"type":"string"},
"content":{"type":"string"},
"branch":{"type":"string"},
"base":{"type":"string"},
"message":{"type":"string"},
"sha":{"type":"string"}
},
"required":["owner","name","path","content","branch","message"]
}`),
}
}
type fileWriteBranchArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Path string `json:"path"`
Content string `json:"content"`
Branch string `json:"branch"`
Base string `json:"base"`
Message string `json:"message"`
Sha string `json:"sha"`
}
func (t *FileWriteBranch) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args fileWriteBranchArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Branch == "" {
return nil, fmt.Errorf("branch is required: %w", gitea.ErrValidation)
}
if args.Message == "" {
return nil, fmt.Errorf("message is required: %w", gitea.ErrValidation)
}
// Resolve base default if branch needs to be created
exists, err := t.c.BranchExists(ctx, args.Owner, args.Name, args.Branch)
if err != nil {
return nil, err
}
if !exists {
base := args.Base
if base == "" {
var err error
base, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
if err != nil {
return nil, err
}
}
if err := t.c.CreateBranch(ctx, args.Owner, args.Name, args.Branch, base); err != nil {
return nil, err
}
}
encoded := base64.StdEncoding.EncodeToString([]byte(args.Content))
result, err := t.c.UpsertFile(ctx, args.Owner, args.Name, args.Path, gitea.UpsertFileArgs{
Branch: args.Branch,
Content: encoded,
Message: args.Message,
Sha: args.Sha,
})
if err != nil {
return nil, err
}
return textOK(map[string]any{
"path": result.Content.Path,
"sha": result.Content.Sha,
"branch": args.Branch,
"commit_sha": result.Commit.Sha,
"html_url": result.Content.HTMLURL,
})
}

View File

@@ -0,0 +1,173 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const branchCheckExistsResp = `{"name":"feat/x","commit":{"id":"abc","url":"http://example.com"}}`
const createBranchResp = `{"name":"feat/new","commit":{"id":"abc","url":"http://example.com"}}`
const upsertFileResp = `{"content":{"path":"doc.md","sha":"filsha","html_url":"http://example.com/doc.md"},"commit":{"sha":"cmt1","html_url":"http://example.com/commit/cmt1"}}`
const getRepoResp = `{"name":"myrepo","full_name":"owner/myrepo","default_branch":"main"}`
func TestFileWriteBranchCreatesBranchAndFile(t *testing.T) {
mux := http.NewServeMux()
// Branch check → 404 (branch doesn't exist)
mux.HandleFunc("/api/v1/repos/owner/myrepo/branches/feat/new", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"branch not found"}`))
}
})
// Create branch → 201
mux.HandleFunc("/api/v1/repos/owner/myrepo/branches", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(createBranchResp))
})
// Upsert file → 201
mux.HandleFunc("/api/v1/repos/owner/myrepo/contents/doc.md", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(upsertFileResp))
})
srv := httptest.NewServer(mux)
defer srv.Close()
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"owner","name":"myrepo","path":"doc.md",
"content":"hello","branch":"feat/new","base":"main",
"message":"add doc.md"
}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, "feat/new", result["branch"])
assert.Equal(t, "doc.md", result["path"])
assert.Equal(t, "cmt1", result["commit_sha"])
}
func TestFileWriteBranchUsesDefaultBaseWhenBaseEmpty(t *testing.T) {
var createBody []byte
mux := http.NewServeMux()
// Branch check → 404
mux.HandleFunc("/api/v1/repos/owner/myrepo/branches/feat/new", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
})
// GET repo (to resolve default_branch)
mux.HandleFunc("/api/v1/repos/owner/myrepo", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(getRepoResp))
})
// Create branch → capture body to assert old_branch_name
mux.HandleFunc("/api/v1/repos/owner/myrepo/branches", func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
var err error
createBody, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(createBranchResp))
})
// Upsert file
mux.HandleFunc("/api/v1/repos/owner/myrepo/contents/doc.md", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(upsertFileResp))
})
srv := httptest.NewServer(mux)
defer srv.Close()
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"owner","name":"myrepo","path":"doc.md",
"content":"hello","branch":"feat/new",
"message":"add doc.md"
}`))
require.NoError(t, err)
require.NotNil(t, out)
var payload map[string]string
require.NoError(t, json.Unmarshal(createBody, &payload))
assert.Equal(t, "main", payload["old_branch_name"])
assert.Equal(t, "feat/new", payload["new_branch_name"])
}
func TestFileWriteBranchSkipsCreateWhenBranchExists(t *testing.T) {
var createCallCount atomic.Int32
mux := http.NewServeMux()
// Branch check → 200 (branch exists)
mux.HandleFunc("/api/v1/repos/owner/myrepo/branches/feat/existing", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(branchCheckExistsResp))
})
// Create branch — should NOT be called
mux.HandleFunc("/api/v1/repos/owner/myrepo/branches", func(w http.ResponseWriter, r *http.Request) {
createCallCount.Add(1)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(createBranchResp))
})
// Upsert file
mux.HandleFunc("/api/v1/repos/owner/myrepo/contents/doc.md", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(upsertFileResp))
})
srv := httptest.NewServer(mux)
defer srv.Close()
tool := tools.NewFileWriteBranch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"owner"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"owner","name":"myrepo","path":"doc.md",
"content":"hello","branch":"feat/existing",
"message":"update doc.md"
}`))
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, int32(0), createCallCount.Load(), "POST /branches should not be called when branch exists")
}
func TestFileWriteBranchAllowlistRejects(t *testing.T) {
tool := tools.NewFileWriteBranch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"evil","name":"repo","path":"f.md",
"content":"x","branch":"feat/x","message":"msg"
}`))
require.Error(t, err)
}
func TestFileWriteBranchRequiresMessage(t *testing.T) {
tool := tools.NewFileWriteBranch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"owner"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"owner","name":"repo","path":"f.md",
"content":"x","branch":"feat/x"
}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}

View File

@@ -0,0 +1,73 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueComment struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueComment(c *gitea.Client, a *allowlist.Allowlist) *IssueComment {
return &IssueComment{c: c, a: a}
}
func (t *IssueComment) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_comment",
Description: "Comment on an issue. Applies identity footer to body.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1},
"body":{"type":"string"}
},
"required":["owner","name","number","body"]
}`),
}
}
type issueCommentArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Number int `json:"number"`
Body string `json:"body"`
}
func (t *IssueComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueCommentArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
}
if args.Body == "" {
return nil, fmt.Errorf("body is required: %w", gitea.ErrValidation)
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
if err != nil {
return nil, err
}
return textOK(map[string]any{
"id": c.ID,
"html_url": c.HTMLURL,
})
}

View File

@@ -0,0 +1,54 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const commentFixture = `{"id":7,"body":"hello","html_url":"http://example.com/issues/42#comment-7"}`
func TestIssueCommentAppliesFooter(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/issues/42/comments", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(commentFixture))
}))
defer srv.Close()
tool := tools.NewIssueComment(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
ctx := callerContext("mathiasbq")
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","number":42,"body":"hello"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
body, _ := payload["body"].(string)
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
}
func TestIssueCommentAllowlistRejects(t *testing.T) {
tool := tools.NewIssueComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1,"body":"hi"}`))
require.Error(t, err)
}
func TestIssueCommentRequiresBody(t *testing.T) {
tool := tools.NewIssueComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1,"body":""}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}

View File

@@ -0,0 +1,84 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type IssueCreate struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewIssueCreate(c *gitea.Client, a *allowlist.Allowlist) *IssueCreate {
return &IssueCreate{c: c, a: a}
}
func (t *IssueCreate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "issue_create",
Description: "Create an issue. Applies identity footer to body.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"title":{"type":"string"},
"body":{"type":"string"},
"labels":{"type":"array","items":{"type":"integer"}},
"assignees":{"type":"array","items":{"type":"string"}},
"milestone":{"type":"integer"}
},
"required":["owner","name","title"]
}`),
}
}
type issueCreateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Title string `json:"title"`
Body string `json:"body"`
Labels []int64 `json:"labels"`
Assignees []string `json:"assignees"`
Milestone int64 `json:"milestone"`
}
func (t *IssueCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args issueCreateArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Title == "" {
return nil, fmt.Errorf("title is required: %w", gitea.ErrValidation)
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
iss, err := t.c.CreateIssue(ctx, args.Owner, args.Name, gitea.CreateIssueArgs{
Title: args.Title,
Body: body,
Labels: args.Labels,
Assignees: args.Assignees,
Milestone: args.Milestone,
})
if err != nil {
return nil, err
}
return textOK(map[string]any{
"number": iss.Number,
"title": iss.Title,
"html_url": iss.HTMLURL,
"state": iss.State,
})
}

View File

@@ -0,0 +1,81 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const issueFixture = `{
"number": 42,
"title": "x",
"body": "y",
"html_url": "http://example.com/issues/42",
"state": "open"
}`
func TestIssueCreateAppliesIdentityFooter(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/issues", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(issueFixture))
}))
defer srv.Close()
tool := tools.NewIssueCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
ctx := callerContext("mathiasbq")
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","title":"x","body":"y"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
body, _ := payload["body"].(string)
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
}
func TestIssueCreateNoFooterWhenCallerEmpty(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(issueFixture))
}))
defer srv.Close()
tool := tools.NewIssueCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","title":"x","body":"y"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
body, _ := payload["body"].(string)
assert.NotContains(t, body, "_Created via git-mcp on behalf of")
}
func TestIssueCreateAllowlistRejects(t *testing.T) {
tool := tools.NewIssueCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","title":"T"}`))
require.Error(t, err)
}
func TestIssueCreateRequiresTitle(t *testing.T) {
tool := tools.NewIssueCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","title":""}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}

View File

@@ -0,0 +1,73 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type PRComment struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewPRComment(c *gitea.Client, a *allowlist.Allowlist) *PRComment {
return &PRComment{c: c, a: a}
}
func (t *PRComment) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "pr_comment",
Description: "Comment on a pull request (conversation, not inline review). Applies identity footer.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1},
"body":{"type":"string"}
},
"required":["owner","name","number","body"]
}`),
}
}
type prCommentArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Number int `json:"number"`
Body string `json:"body"`
}
func (t *PRComment) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args prCommentArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
}
if args.Body == "" {
return nil, fmt.Errorf("body is required: %w", gitea.ErrValidation)
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
c, err := t.c.CreateIssueComment(ctx, args.Owner, args.Name, args.Number, body)
if err != nil {
return nil, err
}
return textOK(map[string]any{
"id": c.ID,
"html_url": c.HTMLURL,
})
}

View File

@@ -0,0 +1,53 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPRCommentAppliesFooter(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// PRs share index space with issues — same endpoint
assert.Equal(t, "/api/v1/repos/o/r/issues/3/comments", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(commentFixture))
}))
defer srv.Close()
tool := tools.NewPRComment(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
ctx := callerContext("mathiasbq")
_, err := tool.Call(ctx, json.RawMessage(`{"owner":"o","name":"r","number":3,"body":"looks good"}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
body, _ := payload["body"].(string)
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
}
func TestPRCommentAllowlistRejects(t *testing.T) {
tool := tools.NewPRComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1,"body":"hi"}`))
require.Error(t, err)
}
func TestPRCommentRequiresBody(t *testing.T) {
tool := tools.NewPRComment(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1,"body":""}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}

View File

@@ -0,0 +1,91 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/identity"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type PRCreate struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewPRCreate(c *gitea.Client, a *allowlist.Allowlist) *PRCreate {
return &PRCreate{c: c, a: a}
}
func (t *PRCreate) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "pr_create",
Description: "Create a pull request. Applies an identity footer to the PR body.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"title":{"type":"string"},
"body":{"type":"string"},
"head":{"type":"string"},
"base":{"type":"string"},
"draft":{"type":"boolean"}
},
"required":["owner","name","title","head","base"]
}`),
}
}
type prCreateArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Title string `json:"title"`
Body string `json:"body"`
Head string `json:"head"`
Base string `json:"base"`
Draft bool `json:"draft"`
}
func (t *PRCreate) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args prCreateArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Title == "" {
return nil, fmt.Errorf("title is required: %w", gitea.ErrValidation)
}
if args.Head == "" || args.Base == "" {
return nil, fmt.Errorf("head and base are required: %w", gitea.ErrValidation)
}
body := identity.ApplyFooter(args.Body, auth.Caller(ctx))
pr, err := t.c.CreatePullRequest(ctx, args.Owner, args.Name, gitea.CreatePullRequestArgs{
Title: args.Title,
Body: body,
Head: args.Head,
Base: args.Base,
Draft: args.Draft,
})
if err != nil {
return nil, err
}
return textOK(map[string]any{
"number": pr.Number,
"title": pr.Title,
"html_url": pr.HTMLURL,
"head": pr.Head.Ref,
"base": pr.Base.Ref,
"state": pr.State,
"draft": pr.Draft,
})
}

View File

@@ -0,0 +1,107 @@
package tools_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/auth"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const prFixture = `{
"number": 3,
"title": "My PR",
"body": "description",
"html_url": "http://example.com/pulls/3",
"state": "open",
"draft": false,
"head": {"ref": "feat/new"},
"base": {"ref": "main"}
}`
func callerContext(user string) context.Context {
var capturedCtx context.Context
h := auth.CallerMiddleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
capturedCtx = r.Context()
}))
req := httptest.NewRequest("POST", "/", nil)
if user != "" {
req.Header.Set("X-Auth-Request-User", user)
}
h.ServeHTTP(httptest.NewRecorder(), req)
return capturedCtx
}
func TestPRCreateAppliesIdentityFooter(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/pulls", r.URL.Path)
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(prFixture))
}))
defer srv.Close()
tool := tools.NewPRCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
ctx := callerContext("mathiasbq")
_, err := tool.Call(ctx, json.RawMessage(`{
"owner":"o","name":"r","title":"My PR","body":"description","head":"feat/new","base":"main"
}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
body, _ := payload["body"].(string)
assert.Contains(t, body, "_Created via git-mcp on behalf of @mathiasbq_")
}
func TestPRCreateNoFooterWhenCallerEmpty(t *testing.T) {
var captured []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
captured, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(prFixture))
}))
defer srv.Close()
tool := tools.NewPRCreate(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"o","name":"r","title":"My PR","body":"description","head":"feat/new","base":"main"
}`))
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(captured, &payload))
body, _ := payload["body"].(string)
assert.False(t, strings.Contains(body, "_Created via git-mcp on behalf of"), "footer should not be present when caller is empty")
}
func TestPRCreateAllowlistRejects(t *testing.T) {
tool := tools.NewPRCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"evil","name":"r","title":"T","head":"feat/x","base":"main"
}`))
require.Error(t, err)
}
func TestPRCreateRequiresTitle(t *testing.T) {
tool := tools.NewPRCreate(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{
"owner":"o","name":"r","title":"","head":"feat/x","base":"main"
}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}

View File

@@ -0,0 +1,171 @@
package tools
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
const (
maxFileDiffBytes = 20 * 1024
maxResponseBytes = 200 * 1024
)
type PRFilesDiff struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewPRFilesDiff(c *gitea.Client, a *allowlist.Allowlist) *PRFilesDiff {
return &PRFilesDiff{c: c, a: a}
}
func (t *PRFilesDiff) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "pr_files_diff",
Description: "Get a pull request's per-file diff with size caps (20KB/file, 200KB total).",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","name","number"]
}`),
}
}
type prFilesDiffArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Number int `json:"number"`
}
type prFileDiffEntry struct {
Path string `json:"path"`
Diff string `json:"diff"`
Truncated bool `json:"truncated"`
OmittedLines int `json:"omitted_lines,omitempty"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
}
func (t *PRFilesDiff) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args prFilesDiffArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
}
files, err := t.c.GetPullRequestFiles(ctx, args.Owner, args.Name, args.Number)
if err != nil {
return nil, err
}
rawDiff, err := t.c.GetPullRequestDiff(ctx, args.Owner, args.Name, args.Number)
if err != nil {
return nil, err
}
// Split unified diff by per-file headers ("diff --git a/path b/path")
perFile := splitUnifiedDiff(rawDiff)
out := struct {
Files []prFileDiffEntry `json:"files"`
OmittedFiles []string `json:"omitted_files,omitempty"`
ResponseTruncated bool `json:"response_truncated"`
}{
Files: make([]prFileDiffEntry, 0, len(files)),
}
totalBytes := 0
for _, f := range files {
// look up the diff for this file (best-effort by path match)
diffBytes, ok := perFile[f.Filename]
if !ok {
diffBytes = []byte{}
}
entry := prFileDiffEntry{
Path: f.Filename,
Additions: f.Additions,
Deletions: f.Deletions,
}
// Per-file cap
if len(diffBytes) > maxFileDiffBytes {
truncated := diffBytes[:maxFileDiffBytes]
omittedLines := bytes.Count(diffBytes[maxFileDiffBytes:], []byte("\n"))
entry.Diff = string(truncated)
entry.Truncated = true
entry.OmittedLines = omittedLines
} else {
entry.Diff = string(diffBytes)
}
// Response cap — if adding this entry would exceed, push to omitted_files
entryEstimate := len(entry.Diff) + 200 // small overhead for path + counts
if totalBytes+entryEstimate > maxResponseBytes {
out.OmittedFiles = append(out.OmittedFiles, f.Filename)
out.ResponseTruncated = true
continue
}
totalBytes += entryEstimate
out.Files = append(out.Files, entry)
}
return textOK(out)
}
// splitUnifiedDiff parses a unified diff and returns a map from filename to that file's
// portion of the diff. The unified diff format starts each file with a line like
// "diff --git a/<path> b/<path>".
func splitUnifiedDiff(d []byte) map[string][]byte {
m := map[string][]byte{}
scanner := bufio.NewScanner(bytes.NewReader(d))
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) // allow long diffs
var currentFile string
var current bytes.Buffer
flush := func() {
if currentFile != "" {
m[currentFile] = []byte(current.String())
current.Reset()
}
}
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "diff --git ") {
flush()
// Parse: "diff --git a/<path> b/<path>"
rest := strings.TrimPrefix(line, "diff --git a/")
parts := strings.SplitN(rest, " b/", 2)
if len(parts) == 2 {
currentFile = parts[0]
} else {
currentFile = ""
}
}
if currentFile != "" {
current.WriteString(line)
current.WriteByte('\n')
}
}
flush()
return m
}

View File

@@ -0,0 +1,183 @@
package tools_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// buildDiff builds a synthetic unified diff for a set of files.
// Each file gets `linesPerFile` added lines.
func buildDiff(files []string, linesPerFile int) string {
var sb strings.Builder
for _, f := range files {
fmt.Fprintf(&sb, "diff --git a/%s b/%s\n", f, f)
fmt.Fprintf(&sb, "--- a/%s\n+++ b/%s\n", f, f)
fmt.Fprintf(&sb, "@@ -0,0 +1,%d @@\n", linesPerFile)
sb.WriteString(strings.Repeat("+abcdefghij\n", linesPerFile))
}
return sb.String()
}
// buildFilesJSON builds the JSON list of PullRequestFile objects.
func buildFilesJSON(files []string, additions int) string {
entries := make([]string, len(files))
for i, f := range files {
entries[i] = fmt.Sprintf(`{"filename":%q,"status":"modified","additions":%d,"deletions":0}`, f, additions)
}
return "[" + strings.Join(entries, ",") + "]"
}
// newPRFilesDiffServer creates a test server that serves both the /files and .diff endpoints.
func newPRFilesDiffServer(t *testing.T, filesJSON, rawDiff string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/repos/o/r/pulls/1/files":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(filesJSON))
case r.URL.Path == "/api/v1/repos/o/r/pulls/1.diff":
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(rawDiff))
default:
t.Errorf("unexpected request: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
}
func TestPRFilesDiffSmall(t *testing.T) {
// Two files, each ~120 bytes of diff — well under per-file and total caps.
fileNames := []string{"main.go", "util.go"}
// ~10 lines each = ~120 bytes per file diff
rawDiff := buildDiff(fileNames, 10)
filesJSON := buildFilesJSON(fileNames, 10)
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
defer srv.Close()
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
require.NoError(t, err)
var out struct {
Files []struct {
Path string `json:"path"`
Diff string `json:"diff"`
Truncated bool `json:"truncated"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
} `json:"files"`
OmittedFiles []string `json:"omitted_files"`
ResponseTruncated bool `json:"response_truncated"`
}
require.NoError(t, json.Unmarshal(result, &out))
assert.Len(t, out.Files, 2)
assert.Empty(t, out.OmittedFiles)
assert.False(t, out.ResponseTruncated)
for _, f := range out.Files {
assert.False(t, f.Truncated, "file %s should not be truncated", f.Path)
assert.NotEmpty(t, f.Diff)
assert.Equal(t, 10, f.Additions)
assert.Equal(t, 0, f.Deletions)
}
paths := []string{out.Files[0].Path, out.Files[1].Path}
assert.ElementsMatch(t, fileNames, paths)
}
func TestPRFilesDiffPerFileTruncated(t *testing.T) {
// One file with a 30KB diff (each "+abcdefghij\n" = 12 bytes; 30KB / 12 ≈ 2560 lines).
fileNames := []string{"bigfile.go"}
linesPerFile := 2560 // ~30720 bytes > 20KB cap
rawDiff := buildDiff(fileNames, linesPerFile)
filesJSON := buildFilesJSON(fileNames, linesPerFile)
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
defer srv.Close()
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
require.NoError(t, err)
var out struct {
Files []struct {
Path string `json:"path"`
Diff string `json:"diff"`
Truncated bool `json:"truncated"`
OmittedLines int `json:"omitted_lines"`
Additions int `json:"additions"`
} `json:"files"`
ResponseTruncated bool `json:"response_truncated"`
}
require.NoError(t, json.Unmarshal(result, &out))
require.Len(t, out.Files, 1)
f := out.Files[0]
assert.Equal(t, "bigfile.go", f.Path)
assert.True(t, f.Truncated, "file should be truncated")
assert.Greater(t, f.OmittedLines, 0, "omitted_lines should be > 0")
assert.LessOrEqual(t, len(f.Diff), 20*1024+200, "diff should be capped near 20KB")
assert.False(t, out.ResponseTruncated)
}
func TestPRFilesDiffResponseCapped(t *testing.T) {
// 25 files × ~10KB diff each = ~250KB raw, well over the 200KB response cap.
// Each file: 850 lines × 12 bytes = 10200 bytes per file.
numFiles := 25
linesPerFile := 850
fileNames := make([]string, numFiles)
for i := range fileNames {
fileNames[i] = fmt.Sprintf("file%02d.go", i)
}
rawDiff := buildDiff(fileNames, linesPerFile)
filesJSON := buildFilesJSON(fileNames, linesPerFile)
srv := newPRFilesDiffServer(t, filesJSON, rawDiff)
defer srv.Close()
tool := tools.NewPRFilesDiff(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
result, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":1}`))
require.NoError(t, err)
var out struct {
Files []struct {
Path string `json:"path"`
} `json:"files"`
OmittedFiles []string `json:"omitted_files"`
ResponseTruncated bool `json:"response_truncated"`
}
require.NoError(t, json.Unmarshal(result, &out))
assert.True(t, out.ResponseTruncated, "response should be truncated")
assert.NotEmpty(t, out.OmittedFiles, "some files should be omitted")
assert.NotEmpty(t, out.Files, "some files should be included")
// Total files accounted for should equal numFiles.
totalAccountedFor := len(out.Files) + len(out.OmittedFiles)
assert.Equal(t, numFiles, totalAccountedFor)
}
func TestPRFilesDiffAllowlistRejects(t *testing.T) {
tool := tools.NewPRFilesDiff(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1}`))
require.Error(t, err)
}
func TestPRFilesDiffRequiresValidNumber(t *testing.T) {
tool := tools.NewPRFilesDiff(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":0}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}

68
internal/tools/pr_get.go Normal file
View File

@@ -0,0 +1,68 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type PRGet struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewPRGet(c *gitea.Client, a *allowlist.Allowlist) *PRGet { return &PRGet{c: c, a: a} }
func (t *PRGet) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "pr_get",
Description: "Get a pull request by number.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"number":{"type":"integer","minimum":1}
},
"required":["owner","name","number"]
}`),
}
}
type prGetArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Number int `json:"number"`
}
func (t *PRGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args prGetArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Number < 1 {
return nil, fmt.Errorf("number must be >= 1: %w", gitea.ErrValidation)
}
pr, err := t.c.GetPullRequest(ctx, args.Owner, args.Name, args.Number)
if err != nil {
return nil, err
}
return textOK(map[string]any{
"number": pr.Number,
"title": pr.Title,
"html_url": pr.HTMLURL,
"head": pr.Head.Ref,
"base": pr.Base.Ref,
"state": pr.State,
"draft": pr.Draft,
})
}

View File

@@ -0,0 +1,61 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPRGetTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/o/r/pulls/42", r.URL.Path)
assert.Equal(t, http.MethodGet, r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"number": 42,
"title": "Fix bug Y",
"body": "Fixes Y",
"html_url": "http://example.com/pulls/42",
"state": "open",
"draft": true,
"head": {"ref": "fix/y"},
"base": {"ref": "main"}
}`))
}))
defer srv.Close()
tool := tools.NewPRGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"o"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":42}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, float64(42), result["number"])
assert.Equal(t, "Fix bug Y", result["title"])
assert.Equal(t, "http://example.com/pulls/42", result["html_url"])
assert.Equal(t, "fix/y", result["head"])
assert.Equal(t, "main", result["base"])
assert.Equal(t, "open", result["state"])
assert.Equal(t, true, result["draft"])
}
func TestPRGetAllowlistRejects(t *testing.T) {
tool := tools.NewPRGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"allowed"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"r","number":1}`))
require.Error(t, err)
}
func TestPRGetRequiresValidNumber(t *testing.T) {
tool := tools.NewPRGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"o"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"o","name":"r","number":0}`))
require.Error(t, err)
assert.ErrorIs(t, err, gitea.ErrValidation)
}

View File

@@ -0,0 +1,49 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoGet struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoGet(c *gitea.Client, a *allowlist.Allowlist) *RepoGet { return &RepoGet{c: c, a: a} }
func (t *RepoGet) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_get",
Description: "Get a repo's metadata.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{"owner":{"type":"string"},"name":{"type":"string"}},
"required":["owner","name"]
}`),
}
}
type repoGetArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
}
func (t *RepoGet) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoGetArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
r, err := t.c.GetRepo(ctx, args.Owner, args.Name)
if err != nil {
return nil, err
}
return textOK(r)
}

View File

@@ -0,0 +1,36 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoGetTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/infra", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"infra","full_name":"mathias/infra","default_branch":"main","private":true}`))
}))
defer srv.Close()
tool := tools.NewRepoGet(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"infra"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
assert.Contains(t, string(out), `"default_branch":"main"`)
}
func TestRepoGetAllowlistRejects(t *testing.T) {
tool := tools.NewRepoGet(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,66 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoList struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoList(c *gitea.Client, a *allowlist.Allowlist) *RepoList {
return &RepoList{c: c, a: a}
}
func (t *RepoList) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_list",
Description: "List repos for a given owner.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["owner"]
}`),
}
}
type repoListArgs struct {
Owner string `json:"owner"`
Page int `json:"page"`
Limit int `json:"limit"`
}
func (t *RepoList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoListArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
args.Limit = capLimit(args.Limit, 30)
if args.Page < 1 {
args.Page = 1
}
repos, err := t.c.ListRepos(ctx, args.Owner, args.Page, args.Limit)
if err != nil {
return nil, err
}
out := map[string]any{
"repos": repos,
}
if len(repos) == args.Limit {
out["next_page"] = args.Page + 1
}
return textOK(out)
}

View File

@@ -0,0 +1,34 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoListTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}]`))
}))
defer srv.Close()
tool := tools.NewRepoList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
}
func TestRepoListAllowlistRejects(t *testing.T) {
tool := tools.NewRepoList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil"}`))
require.Error(t, err)
}

View File

@@ -0,0 +1,90 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
type RepoSearch struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewRepoSearch(c *gitea.Client, a *allowlist.Allowlist) *RepoSearch {
return &RepoSearch{c: c, a: a}
}
func (t *RepoSearch) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "repo_search",
Description: "Search repos by query string. Filters results by owner allowlist.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"q":{"type":"string"},
"owner":{"type":"string"},
"page":{"type":"integer","minimum":1},
"limit":{"type":"integer","minimum":1,"maximum":50}
},
"required":["q"]
}`),
}
}
type repoSearchArgs struct {
Q string `json:"q"`
Owner string `json:"owner"`
Page int `json:"page"`
Limit int `json:"limit"`
}
func (t *RepoSearch) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args repoSearchArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if args.Q == "" {
return nil, fmt.Errorf("q is required: %w", gitea.ErrValidation)
}
if args.Owner != "" {
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
}
if args.Page < 1 {
args.Page = 1
}
args.Limit = capLimit(args.Limit, 30)
repos, err := t.c.SearchRepos(ctx, args.Q, args.Owner, args.Page, args.Limit)
if err != nil {
return nil, err
}
// Post-filter when owner not specified — only allowlisted owners survive.
if args.Owner == "" {
filtered := make([]gitea.Repo, 0, len(repos))
for _, r := range repos {
parts := strings.SplitN(r.FullName, "/", 2)
if len(parts) != 2 {
continue
}
if t.a.Check(parts[0]) == nil {
filtered = append(filtered, r)
}
}
repos = filtered
}
out := map[string]any{"repos": repos}
if len(repos) == args.Limit {
out["next_page"] = args.Page + 1
}
return textOK(out)
}

View File

@@ -0,0 +1,61 @@
package tools_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRepoSearchWithOwner(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/search", r.URL.Path)
assert.Equal(t, "infra", r.URL.Query().Get("q"))
assert.Equal(t, "mathias", r.URL.Query().Get("owner"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"name":"infra","full_name":"mathias/infra","default_branch":"main"}],"ok":true}`))
}))
defer srv.Close()
tool := tools.NewRepoSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"infra","owner":"mathias"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"full_name":"mathias/infra"`)
}
func TestRepoSearchPostFiltersWithoutOwner(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// No owner param expected when owner is empty
assert.Empty(t, r.URL.Query().Get("owner"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"name":"x","full_name":"mathias/x"},{"name":"y","full_name":"evil/y"}],"ok":true}`))
}))
defer srv.Close()
tool := tools.NewRepoSearch(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"q":"x"}`))
require.NoError(t, err)
assert.Contains(t, string(out), `"mathias/x"`)
assert.NotContains(t, string(out), `"evil/y"`)
}
func TestRepoSearchAllowlistRejectsExplicitOwner(t *testing.T) {
tool := tools.NewRepoSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"q":"infra","owner":"evil"}`))
require.Error(t, err)
}
func TestRepoSearchRequiresQ(t *testing.T) {
tool := tools.NewRepoSearch(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{}`))
require.Error(t, err)
assert.True(t, errors.Is(err, gitea.ErrValidation))
}

35
internal/tools/tool.go Normal file
View File

@@ -0,0 +1,35 @@
package tools
import (
"context"
"encoding/json"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
// Tool implements registry.Tool.
type Tool = registry.Tool
func textOK(v any) (json.RawMessage, error) {
return json.Marshal(v)
}
func parseArgs(raw json.RawMessage, dst any) error {
if len(raw) == 0 {
return json.Unmarshal([]byte("{}"), dst)
}
return json.Unmarshal(raw, dst)
}
func _ctx(ctx context.Context) context.Context { return ctx } // stub for future hooks
// capLimit returns a sane page size: 0 or negative → def, > 50 → 50.
func capLimit(in, def int) int {
if in <= 0 {
return def
}
if in > 50 {
return 50
}
return in
}

View File

@@ -0,0 +1,69 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
// WorkflowRunStatus fetches the status of a Gitea Actions run.
type WorkflowRunStatus struct {
c *gitea.Client
a *allowlist.Allowlist
}
func NewWorkflowRunStatus(c *gitea.Client, a *allowlist.Allowlist) *WorkflowRunStatus {
return &WorkflowRunStatus{c: c, a: a}
}
func (t *WorkflowRunStatus) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "workflow_run_status",
Description: "Get the status of a Gitea Actions workflow run.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"run_id":{"type":"integer","minimum":1}
},
"required":["owner","name","run_id"]
}`),
}
}
type workflowRunStatusArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
RunID int64 `json:"run_id"`
}
func (t *WorkflowRunStatus) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args workflowRunStatusArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.RunID < 1 {
return nil, fmt.Errorf("run_id must be >= 1: %w", gitea.ErrValidation)
}
run, err := t.c.GetWorkflowRun(ctx, args.Owner, args.Name, args.RunID)
if err != nil {
return nil, err
}
return textOK(map[string]any{
"run_id": run.ID,
"status": run.Status,
"conclusion": run.Conclusion,
"started_at": run.StartedAt,
"html_url": run.HTMLURL,
})
}

View File

@@ -0,0 +1,56 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowRunStatusTool(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/repos/mathias/myrepo/actions/runs/789", r.URL.Path)
assert.Equal(t, http.MethodGet, r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":789,
"status":"completed",
"conclusion":"success",
"started_at":"2026-05-04T10:00:00Z",
"html_url":"http://gitea.example/mathias/myrepo/actions/runs/789"
}`))
}))
defer srv.Close()
tool := tools.NewWorkflowRunStatus(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}))
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"myrepo","run_id":789}`))
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, float64(789), result["run_id"])
assert.Equal(t, "completed", result["status"])
assert.Equal(t, "success", result["conclusion"])
assert.Equal(t, "2026-05-04T10:00:00Z", result["started_at"])
assert.Equal(t, "http://gitea.example/mathias/myrepo/actions/runs/789", result["html_url"])
}
func TestWorkflowRunStatusAllowlistRejects(t *testing.T) {
tool := tools.NewWorkflowRunStatus(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","run_id":1}`))
require.Error(t, err)
}
func TestWorkflowRunStatusRequiresValidRunID(t *testing.T) {
tool := tools.NewWorkflowRunStatus(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}))
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"repo","run_id":0}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "run_id")
}

View File

@@ -0,0 +1,84 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/registry"
)
// WorkflowRunTrigger triggers a Gitea Actions workflow_dispatch run.
type WorkflowRunTrigger struct {
c *gitea.Client
a *allowlist.Allowlist
baseURL string
}
func NewWorkflowRunTrigger(c *gitea.Client, a *allowlist.Allowlist, baseURL string) *WorkflowRunTrigger {
return &WorkflowRunTrigger{c: c, a: a, baseURL: baseURL}
}
func (t *WorkflowRunTrigger) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{
Name: "workflow_run_trigger",
Description: "Trigger a Gitea Actions workflow_dispatch run.",
InputSchema: json.RawMessage(`{
"type":"object",
"properties":{
"owner":{"type":"string"},
"name":{"type":"string"},
"workflow":{"type":"string"},
"ref":{"type":"string"},
"inputs":{"type":"object"}
},
"required":["owner","name","workflow"]
}`),
}
}
type workflowRunTriggerArgs struct {
Owner string `json:"owner"`
Name string `json:"name"`
Workflow string `json:"workflow"`
Ref string `json:"ref"`
Inputs map[string]any `json:"inputs"`
}
func (t *WorkflowRunTrigger) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) {
var args workflowRunTriggerArgs
if err := parseArgs(raw, &args); err != nil {
return nil, err
}
if err := t.a.Check(args.Owner); err != nil {
return nil, err
}
if args.Workflow == "" {
return nil, fmt.Errorf("workflow is required: %w", gitea.ErrValidation)
}
ref := args.Ref
if ref == "" {
var err error
ref, err = t.c.DefaultBranch(ctx, args.Owner, args.Name)
if err != nil {
return nil, err
}
}
result, err := t.c.DispatchWorkflow(ctx, args.Owner, args.Name, args.Workflow, gitea.DispatchWorkflowArgs{
Ref: ref,
Inputs: args.Inputs,
})
if err != nil {
return nil, err
}
htmlURL := fmt.Sprintf("%s/%s/%s/actions/runs/%d", t.baseURL, args.Owner, args.Name, result.RunID)
return textOK(map[string]any{
"run_id": result.RunID,
"html_url": htmlURL,
})
}

View File

@@ -0,0 +1,86 @@
package tools_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist"
"gitea.d-ma.be/mathias/gitea-mcp/internal/gitea"
"gitea.d-ma.be/mathias/gitea-mcp/internal/tools"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWorkflowRunTriggerSuccess(t *testing.T) {
// Fake server handles both the repo endpoint (default_branch) and the dispatch endpoint.
repoHit := false
dispatchHit := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/repos/mathias/myrepo" && r.Method == http.MethodGet:
repoHit = true
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"name":"myrepo","full_name":"mathias/myrepo","default_branch":"main"}`))
case r.URL.Path == "/api/v1/repos/mathias/myrepo/actions/workflows/ci.yml/dispatches" && r.Method == http.MethodPost:
dispatchHit = true
w.Header().Set("Location", "/api/v1/repos/mathias/myrepo/actions/runs/42")
w.WriteHeader(http.StatusNoContent)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
tool := tools.NewWorkflowRunTrigger(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}), srv.URL)
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"myrepo","workflow":"ci.yml"}`))
require.NoError(t, err)
assert.True(t, repoHit, "expected GET /repo for default branch")
assert.True(t, dispatchHit, "expected POST dispatch")
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, float64(42), result["run_id"])
assert.Contains(t, result["html_url"], "/mathias/myrepo/actions/runs/42")
}
func TestWorkflowRunTriggerExplicitRef(t *testing.T) {
repoHit := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/repos/mathias/myrepo" {
repoHit = true
}
if r.URL.Path == "/api/v1/repos/mathias/myrepo/actions/workflows/ci.yml/dispatches" {
w.Header().Set("Location", "/api/v1/repos/mathias/myrepo/actions/runs/99")
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
tool := tools.NewWorkflowRunTrigger(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"}), srv.URL)
out, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"myrepo","workflow":"ci.yml","ref":"develop"}`))
require.NoError(t, err)
assert.False(t, repoHit, "should not call GET /repo when ref is provided")
var result map[string]any
require.NoError(t, json.Unmarshal(out, &result))
assert.Equal(t, float64(99), result["run_id"])
}
func TestWorkflowRunTriggerAllowlistRejects(t *testing.T) {
tool := tools.NewWorkflowRunTrigger(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "http://unused")
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"repo","workflow":"ci.yml"}`))
require.Error(t, err)
}
func TestWorkflowRunTriggerRequiresWorkflow(t *testing.T) {
// workflow field is present in required schema but let's test empty string fallback guard
tool := tools.NewWorkflowRunTrigger(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"}), "http://unused")
_, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"mathias","name":"repo","workflow":""}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "workflow")
}