Skip to content

Commit 74d4b12

Browse files
authored
Merge pull request #250 from buildkite/feat_add_missing_build_and_job_tools
feat(builds): add cancel_build, rebuild_build, retry_job, and get_job_env tools
2 parents 2bda64a + 13d327d commit 74d4b12

5 files changed

Lines changed: 435 additions & 2 deletions

File tree

pkg/buildkite/builds.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ type BuildsClient interface {
1616
ListByOrg(ctx context.Context, org string, options *buildkite.BuildsListOptions) ([]buildkite.Build, *buildkite.Response, error)
1717
ListByPipeline(ctx context.Context, org, pipelineSlug string, options *buildkite.BuildsListOptions) ([]buildkite.Build, *buildkite.Response, error)
1818
Create(ctx context.Context, org string, pipeline string, b buildkite.CreateBuild) (buildkite.Build, *buildkite.Response, error)
19+
Cancel(ctx context.Context, org, pipeline, buildNumber string) (buildkite.Build, error)
20+
Rebuild(ctx context.Context, org, pipeline, buildNumber string) (buildkite.Build, error)
1921
}
2022

2123
// JobSummary represents a summary of jobs grouped by state, with finished jobs classified as passed/failed
@@ -464,6 +466,74 @@ func CreateBuild() (mcp.Tool, mcp.ToolHandlerFor[CreateBuildArgs, any], []string
464466
}, []string{"write_builds"}
465467
}
466468

469+
type CancelBuildArgs struct {
470+
OrgSlug string `json:"org_slug"`
471+
PipelineSlug string `json:"pipeline_slug"`
472+
BuildNumber string `json:"build_number"`
473+
}
474+
475+
func CancelBuild() (mcp.Tool, mcp.ToolHandlerFor[CancelBuildArgs, any], []string) {
476+
return mcp.Tool{
477+
Name: "cancel_build",
478+
Description: "Cancel a running build on a Buildkite pipeline",
479+
Annotations: &mcp.ToolAnnotations{
480+
Title: "Cancel Build",
481+
},
482+
},
483+
func(ctx context.Context, request *mcp.CallToolRequest, args CancelBuildArgs) (*mcp.CallToolResult, any, error) {
484+
ctx, span := trace.Start(ctx, "buildkite.CancelBuild")
485+
defer span.End()
486+
487+
span.SetAttributes(
488+
attribute.String("org_slug", args.OrgSlug),
489+
attribute.String("pipeline_slug", args.PipelineSlug),
490+
attribute.String("build_number", args.BuildNumber),
491+
)
492+
493+
deps := DepsFromContext(ctx)
494+
build, err := deps.BuildsClient.Cancel(ctx, args.OrgSlug, args.PipelineSlug, args.BuildNumber)
495+
if err != nil {
496+
return handleBuildkiteError(err)
497+
}
498+
499+
return mcpTextResult(span, &build)
500+
}, []string{"write_builds"}
501+
}
502+
503+
type RebuildBuildArgs struct {
504+
OrgSlug string `json:"org_slug"`
505+
PipelineSlug string `json:"pipeline_slug"`
506+
BuildNumber string `json:"build_number"`
507+
}
508+
509+
func RebuildBuild() (mcp.Tool, mcp.ToolHandlerFor[RebuildBuildArgs, any], []string) {
510+
return mcp.Tool{
511+
Name: "rebuild_build",
512+
Description: "Rebuild/retry an entire build on a Buildkite pipeline",
513+
Annotations: &mcp.ToolAnnotations{
514+
Title: "Rebuild Build",
515+
},
516+
},
517+
func(ctx context.Context, request *mcp.CallToolRequest, args RebuildBuildArgs) (*mcp.CallToolResult, any, error) {
518+
ctx, span := trace.Start(ctx, "buildkite.RebuildBuild")
519+
defer span.End()
520+
521+
span.SetAttributes(
522+
attribute.String("org_slug", args.OrgSlug),
523+
attribute.String("pipeline_slug", args.PipelineSlug),
524+
attribute.String("build_number", args.BuildNumber),
525+
)
526+
527+
deps := DepsFromContext(ctx)
528+
build, err := deps.BuildsClient.Rebuild(ctx, args.OrgSlug, args.PipelineSlug, args.BuildNumber)
529+
if err != nil {
530+
return handleBuildkiteError(err)
531+
}
532+
533+
return mcpTextResult(span, &build)
534+
}, []string{"write_builds"}
535+
}
536+
467537
func convertEntries(entries []Entry) map[string]string {
468538
if entries == nil {
469539
return nil

pkg/buildkite/builds_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package buildkite
22

33
import (
44
"context"
5+
"errors"
56
"net/http"
67
"testing"
78

@@ -14,6 +15,8 @@ type MockBuildsClient struct {
1415
ListByPipelineFunc func(ctx context.Context, org string, pipeline string, opt *buildkite.BuildsListOptions) ([]buildkite.Build, *buildkite.Response, error)
1516
GetFunc func(ctx context.Context, org string, pipeline string, id string, opt *buildkite.BuildGetOptions) (buildkite.Build, *buildkite.Response, error)
1617
CreateFunc func(ctx context.Context, org string, pipeline string, b buildkite.CreateBuild) (buildkite.Build, *buildkite.Response, error)
18+
CancelFunc func(ctx context.Context, org, pipeline, buildNumber string) (buildkite.Build, error)
19+
RebuildFunc func(ctx context.Context, org, pipeline, buildNumber string) (buildkite.Build, error)
1720
}
1821

1922
func (m *MockBuildsClient) Get(ctx context.Context, org string, pipeline string, id string, opt *buildkite.BuildGetOptions) (buildkite.Build, *buildkite.Response, error) {
@@ -44,6 +47,20 @@ func (m *MockBuildsClient) Create(ctx context.Context, org string, pipeline stri
4447
return buildkite.Build{}, nil, nil
4548
}
4649

50+
func (m *MockBuildsClient) Cancel(ctx context.Context, org, pipeline, buildNumber string) (buildkite.Build, error) {
51+
if m.CancelFunc != nil {
52+
return m.CancelFunc(ctx, org, pipeline, buildNumber)
53+
}
54+
return buildkite.Build{}, nil
55+
}
56+
57+
func (m *MockBuildsClient) Rebuild(ctx context.Context, org, pipeline, buildNumber string) (buildkite.Build, error) {
58+
if m.RebuildFunc != nil {
59+
return m.RebuildFunc(ctx, org, pipeline, buildNumber)
60+
}
61+
return buildkite.Build{}, nil
62+
}
63+
4764
var _ BuildsClient = (*MockBuildsClient)(nil)
4865

4966
func TestGetBuildDefault(t *testing.T) {
@@ -757,3 +774,129 @@ func TestGetBuildInvalidDetailLevel(t *testing.T) {
757774
textContent := getTextResult(t, result)
758775
assert.Contains(textContent.Text, "detail_level must be 'detailed' or 'full'")
759776
}
777+
778+
func TestCancelBuild(t *testing.T) {
779+
t.Run("ToolDefinition", func(t *testing.T) {
780+
tool, _, _ := CancelBuild()
781+
require.Equal(t, "cancel_build", tool.Name)
782+
require.Contains(t, tool.Description, "Cancel")
783+
})
784+
785+
t.Run("Success", func(t *testing.T) {
786+
assert := require.New(t)
787+
788+
client := &MockBuildsClient{
789+
CancelFunc: func(ctx context.Context, org, pipeline, buildNumber string) (buildkite.Build, error) {
790+
assert.Equal("test-org", org)
791+
assert.Equal("test-pipeline", pipeline)
792+
assert.Equal("42", buildNumber)
793+
return buildkite.Build{
794+
ID: "123",
795+
Number: 42,
796+
State: "canceling",
797+
}, nil
798+
},
799+
}
800+
801+
ctx := ContextWithDeps(context.Background(), ToolDependencies{BuildsClient: client})
802+
_, handler, _ := CancelBuild()
803+
804+
result, _, err := handler(ctx, createMCPRequest(t, map[string]any{}), CancelBuildArgs{
805+
OrgSlug: "test-org",
806+
PipelineSlug: "test-pipeline",
807+
BuildNumber: "42",
808+
})
809+
assert.NoError(err)
810+
811+
textContent := getTextResult(t, result)
812+
assert.Contains(textContent.Text, `"id":"123"`)
813+
assert.Contains(textContent.Text, `"state":"canceling"`)
814+
})
815+
816+
t.Run("Error", func(t *testing.T) {
817+
assert := require.New(t)
818+
819+
client := &MockBuildsClient{
820+
CancelFunc: func(ctx context.Context, org, pipeline, buildNumber string) (buildkite.Build, error) {
821+
return buildkite.Build{}, errors.New("build not found")
822+
},
823+
}
824+
825+
ctx := ContextWithDeps(context.Background(), ToolDependencies{BuildsClient: client})
826+
_, handler, _ := CancelBuild()
827+
828+
result, _, err := handler(ctx, createMCPRequest(t, map[string]any{}), CancelBuildArgs{
829+
OrgSlug: "test-org",
830+
PipelineSlug: "test-pipeline",
831+
BuildNumber: "42",
832+
})
833+
assert.NoError(err)
834+
assert.True(result.IsError)
835+
836+
textContent := getTextResult(t, result)
837+
assert.Contains(textContent.Text, "build not found")
838+
})
839+
}
840+
841+
func TestRebuildBuild(t *testing.T) {
842+
t.Run("ToolDefinition", func(t *testing.T) {
843+
tool, _, _ := RebuildBuild()
844+
require.Equal(t, "rebuild_build", tool.Name)
845+
require.Contains(t, tool.Description, "Rebuild")
846+
})
847+
848+
t.Run("Success", func(t *testing.T) {
849+
assert := require.New(t)
850+
851+
client := &MockBuildsClient{
852+
RebuildFunc: func(ctx context.Context, org, pipeline, buildNumber string) (buildkite.Build, error) {
853+
assert.Equal("test-org", org)
854+
assert.Equal("test-pipeline", pipeline)
855+
assert.Equal("42", buildNumber)
856+
return buildkite.Build{
857+
ID: "456",
858+
Number: 43,
859+
State: "scheduled",
860+
}, nil
861+
},
862+
}
863+
864+
ctx := ContextWithDeps(context.Background(), ToolDependencies{BuildsClient: client})
865+
_, handler, _ := RebuildBuild()
866+
867+
result, _, err := handler(ctx, createMCPRequest(t, map[string]any{}), RebuildBuildArgs{
868+
OrgSlug: "test-org",
869+
PipelineSlug: "test-pipeline",
870+
BuildNumber: "42",
871+
})
872+
assert.NoError(err)
873+
874+
textContent := getTextResult(t, result)
875+
assert.Contains(textContent.Text, `"id":"456"`)
876+
assert.Contains(textContent.Text, `"state":"scheduled"`)
877+
})
878+
879+
t.Run("Error", func(t *testing.T) {
880+
assert := require.New(t)
881+
882+
client := &MockBuildsClient{
883+
RebuildFunc: func(ctx context.Context, org, pipeline, buildNumber string) (buildkite.Build, error) {
884+
return buildkite.Build{}, errors.New("build not found")
885+
},
886+
}
887+
888+
ctx := ContextWithDeps(context.Background(), ToolDependencies{BuildsClient: client})
889+
_, handler, _ := RebuildBuild()
890+
891+
result, _, err := handler(ctx, createMCPRequest(t, map[string]any{}), RebuildBuildArgs{
892+
OrgSlug: "test-org",
893+
PipelineSlug: "test-pipeline",
894+
BuildNumber: "42",
895+
})
896+
assert.NoError(err)
897+
assert.True(result.IsError)
898+
899+
textContent := getTextResult(t, result)
900+
assert.Contains(textContent.Text, "build not found")
901+
})
902+
}

pkg/buildkite/jobs.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111

1212
type JobsClient interface {
1313
UnblockJob(ctx context.Context, org string, pipeline string, buildNumber string, jobID string, opt *buildkite.JobUnblockOptions) (buildkite.Job, *buildkite.Response, error)
14+
RetryJob(ctx context.Context, org string, pipeline string, buildNumber string, jobID string) (buildkite.Job, *buildkite.Response, error)
15+
GetJobEnvironmentVariables(ctx context.Context, org string, pipeline string, buildNumber string, jobID string) (buildkite.JobEnvs, *buildkite.Response, error)
1416
}
1517

1618
// GetJobLogsArgs struct for typed parameters
@@ -65,3 +67,78 @@ func UnblockJob() (mcp.Tool, mcp.ToolHandlerFor[UnblockJobArgs, any], []string)
6567
return mcpTextResult(span, &job)
6668
}, []string{"write_builds"}
6769
}
70+
71+
// RetryJobArgs struct for typed parameters
72+
type RetryJobArgs struct {
73+
OrgSlug string `json:"org_slug"`
74+
PipelineSlug string `json:"pipeline_slug"`
75+
BuildNumber string `json:"build_number"`
76+
JobID string `json:"job_id"`
77+
}
78+
79+
func RetryJob() (mcp.Tool, mcp.ToolHandlerFor[RetryJobArgs, any], []string) {
80+
return mcp.Tool{
81+
Name: "retry_job",
82+
Description: "Retry a specific failed or timed out job in a Buildkite build",
83+
Annotations: &mcp.ToolAnnotations{
84+
Title: "Retry Job",
85+
},
86+
},
87+
func(ctx context.Context, request *mcp.CallToolRequest, args RetryJobArgs) (*mcp.CallToolResult, any, error) {
88+
ctx, span := trace.Start(ctx, "buildkite.RetryJob")
89+
defer span.End()
90+
91+
span.SetAttributes(
92+
attribute.String("org_slug", args.OrgSlug),
93+
attribute.String("pipeline_slug", args.PipelineSlug),
94+
attribute.String("build_number", args.BuildNumber),
95+
attribute.String("job_id", args.JobID),
96+
)
97+
98+
deps := DepsFromContext(ctx)
99+
job, _, err := deps.JobsClient.RetryJob(ctx, args.OrgSlug, args.PipelineSlug, args.BuildNumber, args.JobID)
100+
if err != nil {
101+
return handleBuildkiteError(err)
102+
}
103+
104+
return mcpTextResult(span, &job)
105+
}, []string{"write_builds"}
106+
}
107+
108+
// GetJobEnvironmentVariablesArgs struct for typed parameters
109+
type GetJobEnvironmentVariablesArgs struct {
110+
OrgSlug string `json:"org_slug"`
111+
PipelineSlug string `json:"pipeline_slug"`
112+
BuildNumber string `json:"build_number"`
113+
JobID string `json:"job_id"`
114+
}
115+
116+
func GetJobEnvironmentVariables() (mcp.Tool, mcp.ToolHandlerFor[GetJobEnvironmentVariablesArgs, any], []string) {
117+
return mcp.Tool{
118+
Name: "get_job_env",
119+
Description: "Get the environment variables for a specific job in a Buildkite build",
120+
Annotations: &mcp.ToolAnnotations{
121+
Title: "Get Job Environment Variables",
122+
ReadOnlyHint: true,
123+
},
124+
},
125+
func(ctx context.Context, request *mcp.CallToolRequest, args GetJobEnvironmentVariablesArgs) (*mcp.CallToolResult, any, error) {
126+
ctx, span := trace.Start(ctx, "buildkite.GetJobEnvironmentVariables")
127+
defer span.End()
128+
129+
span.SetAttributes(
130+
attribute.String("org_slug", args.OrgSlug),
131+
attribute.String("pipeline_slug", args.PipelineSlug),
132+
attribute.String("build_number", args.BuildNumber),
133+
attribute.String("job_id", args.JobID),
134+
)
135+
136+
deps := DepsFromContext(ctx)
137+
jobEnvs, _, err := deps.JobsClient.GetJobEnvironmentVariables(ctx, args.OrgSlug, args.PipelineSlug, args.BuildNumber, args.JobID)
138+
if err != nil {
139+
return handleBuildkiteError(err)
140+
}
141+
142+
return mcpTextResult(span, &jobEnvs)
143+
}, []string{"read_job_env"}
144+
}

0 commit comments

Comments
 (0)