diff --git a/cmd/gitea-mcp/main.go b/cmd/gitea-mcp/main.go index 21cd543..5d4032b 100644 --- a/cmd/gitea-mcp/main.go +++ b/cmd/gitea-mcp/main.go @@ -66,8 +66,10 @@ func main() { reg.Register(tools.NewRepoTree(giteaClient, ownerAllow)) reg.Register(tools.NewRepoTopicsUpdate(giteaClient, ownerAllow)) reg.Register(tools.NewIssueGet(giteaClient, ownerAllow)) + reg.Register(tools.NewIssueList(giteaClient, ownerAllow)) reg.Register(tools.NewIssueClose(giteaClient, ownerAllow)) reg.Register(tools.NewIssueReopen(giteaClient, ownerAllow)) + reg.Register(tools.NewWorkflowRunList(giteaClient, ownerAllow)) reg.Register(tools.NewReleaseCreate(giteaClient, ownerAllow)) reg.Register(tools.NewRepoDelete(giteaClient, ownerAllow)) diff --git a/internal/gitea/issues.go b/internal/gitea/issues.go index 4aa8859..9b358a5 100644 --- a/internal/gitea/issues.go +++ b/internal/gitea/issues.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "net/url" + "strconv" ) type Issue struct { @@ -72,6 +74,50 @@ func (c *Client) CreateIssue(ctx context.Context, owner, repo string, args Creat return &iss, nil } +// ListIssuesArgs captures the optional query params for ListIssues. +type ListIssuesArgs struct { + State string // "open" | "closed" | "all" + Labels string // comma-separated label names + Since string // ISO 8601 + Page int + Limit int +} + +// ListIssues fetches issues for a repo. Pulls are excluded server-side +// (type=issues) so they don't leak through the same endpoint. +func (c *Client) ListIssues(ctx context.Context, owner, repo string, args ListIssuesArgs) ([]Issue, error) { + q := url.Values{} + q.Set("type", "issues") + if args.State != "" { + q.Set("state", args.State) + } + if args.Labels != "" { + q.Set("labels", args.Labels) + } + if args.Since != "" { + q.Set("since", args.Since) + } + if args.Page > 0 { + q.Set("page", strconv.Itoa(args.Page)) + } + if args.Limit > 0 { + q.Set("limit", strconv.Itoa(args.Limit)) + } + p := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", owner, repo, q.Encode()) + body, status, err := c.GetJSON(ctx, p) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var issues []Issue + if err := json.Unmarshal(body, &issues); err != nil { + return nil, err + } + return issues, nil +} + // SetIssueState flips an issue between "open" and "closed" via PATCH. // Gitea uses the same endpoint for both transitions. func (c *Client) SetIssueState(ctx context.Context, owner, repo string, number int, state string) (*Issue, error) { diff --git a/internal/gitea/workflows.go b/internal/gitea/workflows.go index 6b53905..353b988 100644 --- a/internal/gitea/workflows.go +++ b/internal/gitea/workflows.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/url" "strconv" "strings" ) @@ -54,11 +55,21 @@ func (c *Client) DispatchWorkflow(ctx context.Context, owner, repo, workflow str // 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"` + ID int64 `json:"id"` + DisplayTitle string `json:"display_title,omitempty"` + Status string `json:"status"` // queued | in_progress | completed + Conclusion string `json:"conclusion"` // success | failure | cancelled | skipped (only when completed) + Event string `json:"event,omitempty"` + HeadSHA string `json:"head_sha,omitempty"` + HeadBranch string `json:"head_branch,omitempty"` + WorkflowID string `json:"workflow_id,omitempty"` + RunNumber int64 `json:"run_number,omitempty"` + StartedAt string `json:"started_at"` + UpdatedAt string `json:"updated_at,omitempty"` + HTMLURL string `json:"html_url"` + Actor struct { + Login string `json:"login"` + } `json:"actor,omitempty"` } // GetWorkflowRun fetches the status of a specific Actions run. @@ -77,3 +88,59 @@ func (c *Client) GetWorkflowRun(ctx context.Context, owner, repo string, runID i } return &run, nil } + +// ListWorkflowRunsArgs captures the optional query params for ListWorkflowRuns. +type ListWorkflowRunsArgs struct { + Branch string + HeadSHA string + Status string // queued | in_progress | completed | all + Event string // push | pull_request | schedule | workflow_dispatch | all + Workflow string + Page int + Limit int +} + +type workflowRunsResponse struct { + TotalCount int64 `json:"total_count"` + WorkflowRuns []WorkflowRun `json:"workflow_runs"` +} + +// ListWorkflowRuns fetches recent Actions runs for a repo with optional filters. +// Status / Event of "all" or "" are treated as no-filter. +func (c *Client) ListWorkflowRuns(ctx context.Context, owner, repo string, args ListWorkflowRunsArgs) (*workflowRunsResponse, error) { + q := url.Values{} + if args.Branch != "" { + q.Set("branch", args.Branch) + } + if args.HeadSHA != "" { + q.Set("head_sha", args.HeadSHA) + } + if args.Status != "" && args.Status != "all" { + q.Set("status", args.Status) + } + if args.Event != "" && args.Event != "all" { + q.Set("event", args.Event) + } + if args.Workflow != "" { + q.Set("workflow", args.Workflow) + } + if args.Page > 0 { + q.Set("page", strconv.Itoa(args.Page)) + } + if args.Limit > 0 { + q.Set("limit", strconv.Itoa(args.Limit)) + } + p := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs?%s", owner, repo, q.Encode()) + body, status, err := c.GetJSON(ctx, p) + if err != nil { + return nil, err + } + if err := MapStatus(status, body); err != nil { + return nil, err + } + var resp workflowRunsResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/internal/tools/issue_list.go b/internal/tools/issue_list.go new file mode 100644 index 0000000..0889cc1 --- /dev/null +++ b/internal/tools/issue_list.go @@ -0,0 +1,83 @@ +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 IssueList struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewIssueList(c *gitea.Client, a *allowlist.Allowlist) *IssueList { + return &IssueList{c: c, a: a} +} + +func (t *IssueList) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "issue_list", + Description: "List issues in a repo with optional filters. PRs are excluded (use pr_list for those).", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "state":{"type":"string","enum":["open","closed","all"]}, + "labels":{"type":"string"}, + "since":{"type":"string"}, + "page":{"type":"integer","minimum":1}, + "limit":{"type":"integer","minimum":1,"maximum":50} + }, + "required":["owner","name"] + }`), + } +} + +type issueListArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + State string `json:"state"` + Labels string `json:"labels"` + Since string `json:"since"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +func (t *IssueList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args issueListArgs + if err := parseArgs(raw, &args); err != nil { + return nil, err + } + if err := t.a.Check(args.Owner); err != nil { + return nil, err + } + if args.State == "" { + args.State = "open" + } + args.Limit = capLimit(args.Limit, 30) + if args.Page < 1 { + args.Page = 1 + } + issues, err := t.c.ListIssues(ctx, args.Owner, args.Name, gitea.ListIssuesArgs{ + State: args.State, + Labels: args.Labels, + Since: args.Since, + Page: args.Page, + Limit: args.Limit, + }) + if err != nil { + return nil, err + } + out := map[string]any{ + "issues": issues, + } + if len(issues) == args.Limit { + out["next_page"] = args.Page + 1 + } + return textOK(out) +} diff --git a/internal/tools/issue_list_test.go b/internal/tools/issue_list_test.go new file mode 100644 index 0000000..7bacb55 --- /dev/null +++ b/internal/tools/issue_list_test.go @@ -0,0 +1,88 @@ +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 TestIssueListTool(t *testing.T) { + tests := []struct { + name string + input string + wantQuery map[string]string + respBody string + assert func(t *testing.T, out string) + }{ + { + name: "happy path defaults", + input: `{"owner":"mathias","name":"infra"}`, + wantQuery: map[string]string{"type": "issues", "state": "open", "page": "1", "limit": "30"}, + respBody: `[{"number":42,"title":"fix auth","state":"open","html_url":"http://gitea.example/m/infra/issues/42"},{"number":41,"title":"add tests","state":"open"}]`, + assert: func(t *testing.T, out string) { + assert.Contains(t, out, `"number":42`) + assert.Contains(t, out, `"number":41`) + }, + }, + { + name: "state filter", + input: `{"owner":"mathias","name":"infra","state":"closed"}`, + wantQuery: map[string]string{"type": "issues", "state": "closed"}, + respBody: `[]`, + assert: func(t *testing.T, out string) { + assert.Contains(t, out, `"issues":[]`) + }, + }, + { + name: "label + since filter", + input: `{"owner":"mathias","name":"infra","labels":"bug,critical","since":"2026-05-01T00:00:00Z"}`, + wantQuery: map[string]string{"labels": "bug,critical", "since": "2026-05-01T00:00:00Z"}, + respBody: `[]`, + assert: func(t *testing.T, out string) {}, + }, + { + name: "empty result", + input: `{"owner":"mathias","name":"infra"}`, + wantQuery: map[string]string{"state": "open"}, + respBody: `[]`, + assert: func(t *testing.T, out string) { + assert.Contains(t, out, `"issues":[]`) + assert.NotContains(t, out, `next_page`) + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/infra/issues", r.URL.Path) + q := r.URL.Query() + for k, v := range tc.wantQuery { + assert.Equal(t, v, q.Get(k), "query param %q", k) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.respBody)) + })) + defer srv.Close() + + tool := tools.NewIssueList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(tc.input)) + require.NoError(t, err) + tc.assert(t, string(out)) + }) + } +} + +func TestIssueListAllowlistRejects(t *testing.T) { + tool := tools.NewIssueList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`)) + require.Error(t, err) +} diff --git a/internal/tools/workflow_run_list.go b/internal/tools/workflow_run_list.go new file mode 100644 index 0000000..f6b486d --- /dev/null +++ b/internal/tools/workflow_run_list.go @@ -0,0 +1,87 @@ +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 WorkflowRunList struct { + c *gitea.Client + a *allowlist.Allowlist +} + +func NewWorkflowRunList(c *gitea.Client, a *allowlist.Allowlist) *WorkflowRunList { + return &WorkflowRunList{c: c, a: a} +} + +func (t *WorkflowRunList) Descriptor() registry.ToolDescriptor { + return registry.ToolDescriptor{ + Name: "workflow_run_list", + Description: "List recent Gitea Actions workflow runs with optional filters (branch, head_sha, status, event, workflow).", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "name":{"type":"string"}, + "branch":{"type":"string"}, + "head_sha":{"type":"string"}, + "status":{"type":"string","enum":["queued","in_progress","completed","all"]}, + "event":{"type":"string","enum":["push","pull_request","schedule","workflow_dispatch","all"]}, + "workflow":{"type":"string"}, + "page":{"type":"integer","minimum":1}, + "limit":{"type":"integer","minimum":1,"maximum":50} + }, + "required":["owner","name"] + }`), + } +} + +type workflowRunListArgs struct { + Owner string `json:"owner"` + Name string `json:"name"` + Branch string `json:"branch"` + HeadSHA string `json:"head_sha"` + Status string `json:"status"` + Event string `json:"event"` + Workflow string `json:"workflow"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +func (t *WorkflowRunList) Call(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { + var args workflowRunListArgs + 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, 10) + if args.Page < 1 { + args.Page = 1 + } + resp, err := t.c.ListWorkflowRuns(ctx, args.Owner, args.Name, gitea.ListWorkflowRunsArgs{ + Branch: args.Branch, + HeadSHA: args.HeadSHA, + Status: args.Status, + Event: args.Event, + Workflow: args.Workflow, + Page: args.Page, + Limit: args.Limit, + }) + if err != nil { + return nil, err + } + out := map[string]any{ + "runs": resp.WorkflowRuns, + "total": resp.TotalCount, + } + if len(resp.WorkflowRuns) == args.Limit { + out["next_page"] = args.Page + 1 + } + return textOK(out) +} diff --git a/internal/tools/workflow_run_list_test.go b/internal/tools/workflow_run_list_test.go new file mode 100644 index 0000000..99abd64 --- /dev/null +++ b/internal/tools/workflow_run_list_test.go @@ -0,0 +1,98 @@ +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 TestWorkflowRunListTool(t *testing.T) { + tests := []struct { + name string + input string + wantQuery map[string]string + notQuery []string + respBody string + assert func(t *testing.T, out string) + }{ + { + name: "happy path defaults", + input: `{"owner":"mathias","name":"gitea-mcp"}`, + wantQuery: map[string]string{"page": "1", "limit": "10"}, + respBody: `{"total_count":2,"workflow_runs":[{"id":823,"status":"completed","conclusion":"success","head_sha":"dc907fb"},{"id":822,"status":"completed","conclusion":"success","head_sha":"c4bd339"}]}`, + assert: func(t *testing.T, out string) { + assert.Contains(t, out, `"id":823`) + assert.Contains(t, out, `"total":2`) + }, + }, + { + name: "head_sha short filter", + input: `{"owner":"mathias","name":"gitea-mcp","head_sha":"dc907fb"}`, + wantQuery: map[string]string{"head_sha": "dc907fb"}, + respBody: `{"total_count":1,"workflow_runs":[{"id":823,"status":"completed","conclusion":"success","head_sha":"dc907fb"}]}`, + assert: func(t *testing.T, out string) { + assert.Contains(t, out, `"id":823`) + }, + }, + { + name: "status filter", + input: `{"owner":"mathias","name":"gitea-mcp","status":"in_progress"}`, + wantQuery: map[string]string{"status": "in_progress"}, + respBody: `{"total_count":0,"workflow_runs":[]}`, + assert: func(t *testing.T, out string) { + assert.Contains(t, out, `"runs":[]`) + }, + }, + { + name: "status=all is no-op", + input: `{"owner":"mathias","name":"gitea-mcp","status":"all"}`, + notQuery: []string{"status"}, + respBody: `{"total_count":0,"workflow_runs":[]}`, + assert: func(t *testing.T, out string) {}, + }, + { + name: "branch filter", + input: `{"owner":"mathias","name":"gitea-mcp","branch":"main"}`, + wantQuery: map[string]string{"branch": "main"}, + respBody: `{"total_count":0,"workflow_runs":[]}`, + assert: func(t *testing.T, out string) {}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/repos/mathias/gitea-mcp/actions/runs", r.URL.Path) + q := r.URL.Query() + for k, v := range tc.wantQuery { + assert.Equal(t, v, q.Get(k), "query param %q", k) + } + for _, k := range tc.notQuery { + assert.Equal(t, "", q.Get(k), "query param %q should be absent", k) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tc.respBody)) + })) + defer srv.Close() + + tool := tools.NewWorkflowRunList(gitea.NewClient(srv.URL, "tok"), allowlist.New([]string{"mathias"})) + out, err := tool.Call(context.Background(), json.RawMessage(tc.input)) + require.NoError(t, err) + tc.assert(t, string(out)) + }) + } +} + +func TestWorkflowRunListAllowlistRejects(t *testing.T) { + tool := tools.NewWorkflowRunList(gitea.NewClient("http://unused", ""), allowlist.New([]string{"mathias"})) + _, err := tool.Call(context.Background(), json.RawMessage(`{"owner":"evil","name":"x"}`)) + require.Error(t, err) +}