diff --git a/pkg/buildkite/annotations.go b/pkg/buildkite/annotations.go index b28ab79..63b0583 100644 --- a/pkg/buildkite/annotations.go +++ b/pkg/buildkite/annotations.go @@ -4,29 +4,71 @@ import ( "context" "github.com/buildkite/buildkite-mcp-server/pkg/trace" + "github.com/buildkite/buildkite-mcp-server/pkg/utils" "github.com/buildkite/go-buildkite/v4" "github.com/modelcontextprotocol/go-sdk/mcp" "go.opentelemetry.io/otel/attribute" ) +const ( + annotationScopeBuild = "build" + annotationScopeJob = "job" +) + // AnnotationsClient describes the subset of the Buildkite client we need for annotations. type AnnotationsClient interface { ListByBuild(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error) + Create(ctx context.Context, org, pipelineSlug, buildNumber string, ac buildkite.AnnotationCreate) (buildkite.Annotation, *buildkite.Response, error) + ListByJob(ctx context.Context, org, pipelineSlug, buildNumber, jobID string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error) + CreateForJob(ctx context.Context, org, pipelineSlug, buildNumber, jobID string, ac buildkite.AnnotationCreate) (buildkite.Annotation, *buildkite.Response, error) } type ListAnnotationsArgs struct { OrgSlug string `json:"org_slug"` PipelineSlug string `json:"pipeline_slug"` BuildNumber string `json:"build_number"` + Scope string `json:"scope,omitempty" jsonschema:"Annotation scope: build or job (defaults to build)"` + JobID string `json:"job_id,omitempty" jsonschema:"Job ID required when scope is job"` Page int `json:"page,omitempty" jsonschema:"Page number for pagination (min 1)"` PerPage int `json:"per_page,omitempty" jsonschema:"Results per page for pagination (min 1\\, max 100)"` } -// ListAnnotations returns an MCP tool + handler pair that lists annotations for a build. +type CreateAnnotationArgs struct { + OrgSlug string `json:"org_slug"` + PipelineSlug string `json:"pipeline_slug"` + BuildNumber string `json:"build_number"` + Scope string `json:"scope,omitempty" jsonschema:"Annotation scope: build or job (defaults to build)"` + JobID string `json:"job_id,omitempty" jsonschema:"Job ID required when scope is job"` + Body string `json:"body" jsonschema:"The annotation body as HTML or Markdown"` + Style string `json:"style,omitempty" jsonschema:"Optional annotation style: success, info, warning, or error"` + Priority int `json:"priority,omitempty" jsonschema:"Optional annotation priority from 1 to 10"` + Context string `json:"context,omitempty" jsonschema:"Optional annotation context used to identify or append to an annotation"` + Append bool `json:"append,omitempty" jsonschema:"Append the body to an existing annotation with the same context"` +} + +func normalizeAnnotationScope(scope, jobID string) (string, string) { + if scope == "" { + scope = annotationScopeBuild + } + + switch scope { + case annotationScopeBuild: + return scope, "" + case annotationScopeJob: + if jobID == "" { + return "", "job_id is required when scope is 'job'" + } + return scope, "" + default: + return "", "scope must be 'build' or 'job'" + } +} + +// ListAnnotations returns an MCP tool + handler pair that lists annotations for a build or job. func ListAnnotations() (mcp.Tool, mcp.ToolHandlerFor[ListAnnotationsArgs, any], []string) { return mcp.Tool{ Name: "list_annotations", - Description: "List all annotations for a build, including their context, style (success/info/warning/error), rendered HTML content, and creation timestamps", + Description: "List annotations for a build or a specific job. Use scope='build' (default) or scope='job' with job_id", Annotations: &mcp.ToolAnnotations{ Title: "List Annotations", ReadOnlyHint: true, @@ -35,20 +77,40 @@ func ListAnnotations() (mcp.Tool, mcp.ToolHandlerFor[ListAnnotationsArgs, any], ctx, span := trace.Start(ctx, "buildkite.ListAnnotations") defer span.End() + scope, validationErr := normalizeAnnotationScope(args.Scope, args.JobID) + if validationErr != "" { + return utils.NewToolResultError(validationErr), nil, nil + } + paginationParams := paginationFromArgs(args.Page, args.PerPage) span.SetAttributes( attribute.String("org_slug", args.OrgSlug), attribute.String("pipeline_slug", args.PipelineSlug), attribute.String("build_number", args.BuildNumber), + attribute.String("scope", scope), + attribute.String("job_id", args.JobID), attribute.Int("page", paginationParams.Page), attribute.Int("per_page", paginationParams.PerPage), ) deps := DepsFromContext(ctx) - annotations, resp, err := deps.AnnotationsClient.ListByBuild(ctx, args.OrgSlug, args.PipelineSlug, args.BuildNumber, &buildkite.AnnotationListOptions{ - ListOptions: paginationParams, - }) + + var ( + annotations []buildkite.Annotation + resp *buildkite.Response + err error + ) + + if scope == annotationScopeJob { + annotations, resp, err = deps.AnnotationsClient.ListByJob(ctx, args.OrgSlug, args.PipelineSlug, args.BuildNumber, args.JobID, &buildkite.AnnotationListOptions{ + ListOptions: paginationParams, + }) + } else { + annotations, resp, err = deps.AnnotationsClient.ListByBuild(ctx, args.OrgSlug, args.PipelineSlug, args.BuildNumber, &buildkite.AnnotationListOptions{ + ListOptions: paginationParams, + }) + } if err != nil { return handleBuildkiteError(err) } @@ -67,3 +129,60 @@ func ListAnnotations() (mcp.Tool, mcp.ToolHandlerFor[ListAnnotationsArgs, any], return mcpTextResult(span, &result) }, []string{"read_builds"} } + +// CreateAnnotation returns an MCP tool + handler pair that creates an annotation on a build or job. +func CreateAnnotation() (mcp.Tool, mcp.ToolHandlerFor[CreateAnnotationArgs, any], []string) { + return mcp.Tool{ + Name: "create_annotation", + Description: "Create an annotation on a build or specific job. Use scope='build' (default) or scope='job' with job_id", + Annotations: &mcp.ToolAnnotations{ + Title: "Create Annotation", + }, + }, func(ctx context.Context, request *mcp.CallToolRequest, args CreateAnnotationArgs) (*mcp.CallToolResult, any, error) { + ctx, span := trace.Start(ctx, "buildkite.CreateAnnotation") + defer span.End() + + scope, validationErr := normalizeAnnotationScope(args.Scope, args.JobID) + if validationErr != "" { + return utils.NewToolResultError(validationErr), nil, nil + } + + span.SetAttributes( + attribute.String("org_slug", args.OrgSlug), + attribute.String("pipeline_slug", args.PipelineSlug), + attribute.String("build_number", args.BuildNumber), + attribute.String("scope", scope), + attribute.String("job_id", args.JobID), + attribute.String("context", args.Context), + attribute.String("style", args.Style), + attribute.Int("priority", args.Priority), + attribute.Bool("append", args.Append), + ) + + create := buildkite.AnnotationCreate{ + Body: args.Body, + Context: args.Context, + Style: args.Style, + Priority: args.Priority, + Append: args.Append, + } + + deps := DepsFromContext(ctx) + + var ( + annotation buildkite.Annotation + err error + ) + + if scope == annotationScopeJob { + annotation, _, err = deps.AnnotationsClient.CreateForJob(ctx, args.OrgSlug, args.PipelineSlug, args.BuildNumber, args.JobID, create) + } else { + annotation, _, err = deps.AnnotationsClient.Create(ctx, args.OrgSlug, args.PipelineSlug, args.BuildNumber, create) + } + if err != nil { + return handleBuildkiteError(err) + } + + return mcpTextResult(span, &annotation) + }, []string{"write_builds"} +} diff --git a/pkg/buildkite/annotations_test.go b/pkg/buildkite/annotations_test.go index ad15e11..99d2f56 100644 --- a/pkg/buildkite/annotations_test.go +++ b/pkg/buildkite/annotations_test.go @@ -10,8 +10,10 @@ import ( ) type MockAnnotationsClient struct { - ListByBuildFunc func(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error) - GetFunc func(ctx context.Context, org, pipelineSlug, buildNumber, id string) (buildkite.Annotation, *buildkite.Response, error) + ListByBuildFunc func(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error) + CreateFunc func(ctx context.Context, org, pipelineSlug, buildNumber string, ac buildkite.AnnotationCreate) (buildkite.Annotation, *buildkite.Response, error) + ListByJobFunc func(ctx context.Context, org, pipelineSlug, buildNumber, jobID string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error) + CreateForJobFunc func(ctx context.Context, org, pipelineSlug, buildNumber, jobID string, ac buildkite.AnnotationCreate) (buildkite.Annotation, *buildkite.Response, error) } func (m *MockAnnotationsClient) ListByBuild(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error) { @@ -21,6 +23,27 @@ func (m *MockAnnotationsClient) ListByBuild(ctx context.Context, org, pipelineSl return nil, nil, nil } +func (m *MockAnnotationsClient) Create(ctx context.Context, org, pipelineSlug, buildNumber string, ac buildkite.AnnotationCreate) (buildkite.Annotation, *buildkite.Response, error) { + if m.CreateFunc != nil { + return m.CreateFunc(ctx, org, pipelineSlug, buildNumber, ac) + } + return buildkite.Annotation{}, nil, nil +} + +func (m *MockAnnotationsClient) ListByJob(ctx context.Context, org, pipelineSlug, buildNumber, jobID string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error) { + if m.ListByJobFunc != nil { + return m.ListByJobFunc(ctx, org, pipelineSlug, buildNumber, jobID, opts) + } + return nil, nil, nil +} + +func (m *MockAnnotationsClient) CreateForJob(ctx context.Context, org, pipelineSlug, buildNumber, jobID string, ac buildkite.AnnotationCreate) (buildkite.Annotation, *buildkite.Response, error) { + if m.CreateForJobFunc != nil { + return m.CreateForJobFunc(ctx, org, pipelineSlug, buildNumber, jobID, ac) + } + return buildkite.Annotation{}, nil, nil +} + var _ AnnotationsClient = (*MockAnnotationsClient)(nil) func TestListAnnotations(t *testing.T) { @@ -29,19 +52,9 @@ func TestListAnnotations(t *testing.T) { client := &MockAnnotationsClient{ ListByBuildFunc: func(ctx context.Context, org, pipelineSlug, buildNumber string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error) { return []buildkite.Annotation{ - { - ID: "1", - BodyHTML: "Test annotation 1", - }, - { - ID: "2", - BodyHTML: "Test annotation 2", - }, - }, &buildkite.Response{ - Response: &http.Response{ - StatusCode: 200, - }, - }, nil + {ID: "1", BodyHTML: "Test annotation 1"}, + {ID: "2", BodyHTML: "Test annotation 2"}, + }, &buildkite.Response{Response: &http.Response{StatusCode: 200}}, nil }, } @@ -50,14 +63,175 @@ func TestListAnnotations(t *testing.T) { tool, handler, _ := ListAnnotations() assert.NotNil(tool) assert.NotNil(handler) - request := createMCPRequest(t, map[string]any{}) - result, _, err := handler(ctx, request, ListAnnotationsArgs{ + + result, _, err := handler(ctx, createMCPRequest(t, map[string]any{}), ListAnnotationsArgs{ OrgSlug: "org", PipelineSlug: "pipeline", BuildNumber: "1", }) assert.NoError(err) - textContent := getTextResult(t, result) + textContent := getTextResult(t, result) assert.JSONEq(`{"headers":{"Link":""},"items":[{"id":"1","body_html":"Test annotation 1"},{"id":"2","body_html":"Test annotation 2"}]}`, textContent.Text) } + +func TestListAnnotationsForJobScope(t *testing.T) { + assert := require.New(t) + + client := &MockAnnotationsClient{ + ListByJobFunc: func(ctx context.Context, org, pipelineSlug, buildNumber, jobID string, opts *buildkite.AnnotationListOptions) ([]buildkite.Annotation, *buildkite.Response, error) { + assert.Equal("job-1", jobID) + return []buildkite.Annotation{{ID: "1", Scope: "job", BodyHTML: "Job annotation"}}, &buildkite.Response{Response: &http.Response{StatusCode: 200}}, nil + }, + } + + ctx := ContextWithDeps(context.Background(), ToolDependencies{AnnotationsClient: client}) + + tool, handler, _ := ListAnnotations() + assert.NotNil(tool) + assert.NotNil(handler) + + result, _, err := handler(ctx, createMCPRequest(t, map[string]any{}), ListAnnotationsArgs{ + OrgSlug: "org", + PipelineSlug: "pipeline", + BuildNumber: "1", + Scope: "job", + JobID: "job-1", + }) + assert.NoError(err) + + textContent := getTextResult(t, result) + assert.JSONEq(`{"headers":{"Link":""},"items":[{"id":"1","scope":"job","body_html":"Job annotation"}]}`, textContent.Text) +} + +func TestListAnnotationsRequiresJobIDForJobScope(t *testing.T) { + assert := require.New(t) + + ctx := ContextWithDeps(context.Background(), ToolDependencies{AnnotationsClient: &MockAnnotationsClient{}}) + + _, handler, _ := ListAnnotations() + result, _, err := handler(ctx, createMCPRequest(t, map[string]any{}), ListAnnotationsArgs{ + OrgSlug: "org", + PipelineSlug: "pipeline", + BuildNumber: "1", + Scope: "job", + }) + assert.NoError(err) + assert.True(result.IsError) + assert.Contains(getTextResult(t, result).Text, "job_id is required when scope is 'job'") +} + +func TestCreateAnnotation(t *testing.T) { + assert := require.New(t) + + client := &MockAnnotationsClient{ + CreateFunc: func(ctx context.Context, org, pipelineSlug, buildNumber string, ac buildkite.AnnotationCreate) (buildkite.Annotation, *buildkite.Response, error) { + assert.Equal("org", org) + assert.Equal("pipeline", pipelineSlug) + assert.Equal("1", buildNumber) + assert.Equal(buildkite.AnnotationCreate{ + Body: "Hello world!", + Context: "greeting", + Style: "info", + Priority: 5, + Append: true, + }, ac) + + return buildkite.Annotation{ + ID: "ann-1", + Context: "greeting", + Style: "info", + Scope: "build", + Priority: 5, + BodyHTML: "

Hello world!

", + }, &buildkite.Response{Response: &http.Response{StatusCode: 201}}, nil + }, + } + + ctx := ContextWithDeps(context.Background(), ToolDependencies{AnnotationsClient: client}) + + tool, handler, _ := CreateAnnotation() + assert.NotNil(tool) + assert.NotNil(handler) + + result, _, err := handler(ctx, createMCPRequest(t, map[string]any{}), CreateAnnotationArgs{ + OrgSlug: "org", + PipelineSlug: "pipeline", + BuildNumber: "1", + Body: "Hello world!", + Context: "greeting", + Style: "info", + Priority: 5, + Append: true, + }) + assert.NoError(err) + + textContent := getTextResult(t, result) + assert.JSONEq(`{"id":"ann-1","context":"greeting","style":"info","scope":"build","priority":5,"body_html":"

Hello world!

"}`, textContent.Text) +} + +func TestCreateAnnotationForJobScope(t *testing.T) { + assert := require.New(t) + + client := &MockAnnotationsClient{ + CreateForJobFunc: func(ctx context.Context, org, pipelineSlug, buildNumber, jobID string, ac buildkite.AnnotationCreate) (buildkite.Annotation, *buildkite.Response, error) { + assert.Equal("job-1", jobID) + assert.Equal(buildkite.AnnotationCreate{ + Body: "Tests passed", + Context: "test-results", + Style: "success", + Priority: 3, + Append: false, + }, ac) + + return buildkite.Annotation{ + ID: "ann-job-1", + Context: "test-results", + Style: "success", + Scope: "job", + Priority: 3, + BodyHTML: "

Tests passed

", + }, &buildkite.Response{Response: &http.Response{StatusCode: 201}}, nil + }, + } + + ctx := ContextWithDeps(context.Background(), ToolDependencies{AnnotationsClient: client}) + + tool, handler, _ := CreateAnnotation() + assert.NotNil(tool) + assert.NotNil(handler) + + result, _, err := handler(ctx, createMCPRequest(t, map[string]any{}), CreateAnnotationArgs{ + OrgSlug: "org", + PipelineSlug: "pipeline", + BuildNumber: "1", + Scope: "job", + JobID: "job-1", + Body: "Tests passed", + Context: "test-results", + Style: "success", + Priority: 3, + }) + assert.NoError(err) + + textContent := getTextResult(t, result) + assert.JSONEq(`{"id":"ann-job-1","context":"test-results","style":"success","scope":"job","priority":3,"body_html":"

Tests passed

"}`, textContent.Text) +} + +func TestCreateAnnotationRejectsInvalidScope(t *testing.T) { + assert := require.New(t) + + ctx := ContextWithDeps(context.Background(), ToolDependencies{AnnotationsClient: &MockAnnotationsClient{}}) + + _, handler, _ := CreateAnnotation() + result, _, err := handler(ctx, createMCPRequest(t, map[string]any{}), CreateAnnotationArgs{ + OrgSlug: "org", + PipelineSlug: "pipeline", + BuildNumber: "1", + Scope: "wat", + Body: "Hello world!", + }) + assert.NoError(err) + assert.True(result.IsError) + assert.Contains(getTextResult(t, result).Text, "scope must be 'build' or 'job'") +} diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index eec1245..5177b3d 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -363,9 +363,10 @@ func CreateBuiltinToolsets() map[string]Toolset { }, ToolsetAnnotations: { Name: "Annotation Management", - Description: "Tools for managing build annotations", + Description: "Tools for managing build and job annotations", Tools: []ToolDefinition{ newToolDef(buildkite.ListAnnotations), + newToolDef(buildkite.CreateAnnotation), }, }, ToolsetUser: {