Skip to content

Commit 23113b1

Browse files
authored
Merge pull request #160 from cnunciato/cnunciato/add-webhook
feat: support auto-creation of webhooks with new pipelines
2 parents 387bef6 + 2b3a7c5 commit 23113b1

File tree

2 files changed

+230
-9
lines changed

2 files changed

+230
-9
lines changed

pkg/buildkite/pipelines.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type PipelinesClient interface {
1717
List(ctx context.Context, org string, options *buildkite.PipelineListOptions) ([]buildkite.Pipeline, *buildkite.Response, error)
1818
Create(ctx context.Context, org string, p buildkite.CreatePipeline) (buildkite.Pipeline, *buildkite.Response, error)
1919
Update(ctx context.Context, org, pipelineSlug string, p buildkite.UpdatePipeline) (buildkite.Pipeline, *buildkite.Response, error)
20+
AddWebhook(ctx context.Context, org, slug string) (*buildkite.Response, error)
2021
}
2122

2223
type ListPipelinesArgs struct {
@@ -28,6 +29,17 @@ type ListPipelinesArgs struct {
2829
DetailLevel string `json:"detail_level"` // "summary", "detailed", "full"
2930
}
3031

32+
type CreatePipelineResult struct {
33+
Pipeline buildkite.Pipeline `json:"pipeline"`
34+
Webhook *WebhookInfo `json:"webhook,omitempty"`
35+
}
36+
37+
type WebhookInfo struct {
38+
Created bool `json:"created"`
39+
Error string `json:"error,omitempty"`
40+
Note string `json:"note,omitempty"`
41+
}
42+
3143
func ListPipelines(client PipelinesClient) (tool mcp.Tool, handler mcp.TypedToolHandlerFunc[ListPipelinesArgs], scopes []string) {
3244
return mcp.NewTool("list_pipelines",
3345
mcp.WithDescription("List all pipelines in an organization with their basic details, build counts, and current status"),
@@ -282,6 +294,7 @@ type CreatePipelineArgs struct {
282294
SkipQueuedBranchBuilds bool `json:"skip_queued_branch_builds"`
283295
CancelRunningBranchBuilds bool `json:"cancel_running_branch_builds"`
284296
Tags []string `json:"tags"`
297+
CreateWebhook *bool `json:"create_webhook"`
285298
}
286299

287300
func CreatePipeline(client PipelinesClient) (tool mcp.Tool, handler mcp.TypedToolHandlerFunc[CreatePipelineArgs], scopes []string) {
@@ -314,6 +327,10 @@ func CreatePipeline(client PipelinesClient) (tool mcp.Tool, handler mcp.TypedToo
314327
mcp.WithBoolean("cancel_running_branch_builds",
315328
mcp.Description("Cancel running builds when new builds are created on the same branch"),
316329
),
330+
mcp.WithBoolean("create_webhook",
331+
mcp.Description("Create a GitHub webhook to trigger builds in response to pull-request and push events"),
332+
mcp.DefaultBool(true),
333+
),
317334
mcp.WithArray("tags",
318335
mcp.Description("Tags to apply to the pipeline. These can be used for filtering and organization"),
319336
mcp.Items(map[string]any{
@@ -383,7 +400,34 @@ func CreatePipeline(client PipelinesClient) (tool mcp.Tool, handler mcp.TypedToo
383400
return mcp.NewToolResultError(err.Error()), nil
384401
}
385402

386-
return mcpTextResult(span, &pipeline)
403+
// create webhooks by default to align with the behavior in the dashboard
404+
createWebhook := true
405+
if args.CreateWebhook != nil {
406+
createWebhook = *args.CreateWebhook
407+
}
408+
409+
if createWebhook {
410+
_, err := client.AddWebhook(ctx, args.OrgSlug, pipeline.Slug)
411+
result := CreatePipelineResult{
412+
Pipeline: pipeline,
413+
Webhook: &WebhookInfo{
414+
Created: err == nil,
415+
Note: "Pipeline and webhook created successfully.",
416+
},
417+
}
418+
419+
if err != nil {
420+
result.Webhook.Error = err.Error()
421+
result.Webhook.Note = "Pipeline created successfully, but webhook creation failed."
422+
}
423+
424+
return mcpTextResult(span, &result)
425+
}
426+
427+
result := CreatePipelineResult{
428+
Pipeline: pipeline,
429+
}
430+
return mcpTextResult(span, &result)
387431
}, []string{"write_pipelines"}
388432
}
389433

pkg/buildkite/pipelines_test.go

Lines changed: 185 additions & 8 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

@@ -10,10 +11,11 @@ import (
1011
)
1112

1213
type MockPipelinesClient struct {
13-
GetFunc func(ctx context.Context, org string, pipeline string) (buildkite.Pipeline, *buildkite.Response, error)
14-
ListFunc func(ctx context.Context, org string, opt *buildkite.PipelineListOptions) ([]buildkite.Pipeline, *buildkite.Response, error)
15-
CreateFunc func(ctx context.Context, org string, p buildkite.CreatePipeline) (buildkite.Pipeline, *buildkite.Response, error)
16-
UpdateFunc func(ctx context.Context, org string, pipeline string, p buildkite.UpdatePipeline) (buildkite.Pipeline, *buildkite.Response, error)
14+
GetFunc func(ctx context.Context, org string, pipeline string) (buildkite.Pipeline, *buildkite.Response, error)
15+
ListFunc func(ctx context.Context, org string, opt *buildkite.PipelineListOptions) ([]buildkite.Pipeline, *buildkite.Response, error)
16+
CreateFunc func(ctx context.Context, org string, p buildkite.CreatePipeline) (buildkite.Pipeline, *buildkite.Response, error)
17+
UpdateFunc func(ctx context.Context, org string, pipeline string, p buildkite.UpdatePipeline) (buildkite.Pipeline, *buildkite.Response, error)
18+
AddWebhookFunc func(ctx context.Context, org string, slug string) (*buildkite.Response, error)
1719
}
1820

1921
func (m *MockPipelinesClient) Get(ctx context.Context, org string, pipeline string) (buildkite.Pipeline, *buildkite.Response, error) {
@@ -44,6 +46,13 @@ func (m *MockPipelinesClient) Update(ctx context.Context, org string, pipeline s
4446
return buildkite.Pipeline{}, nil, nil
4547
}
4648

49+
func (m *MockPipelinesClient) AddWebhook(ctx context.Context, org string, slug string) (*buildkite.Response, error) {
50+
if m.AddWebhookFunc != nil {
51+
return m.AddWebhookFunc(ctx, org, slug)
52+
}
53+
return &buildkite.Response{Response: &http.Response{StatusCode: 201}}, nil
54+
}
55+
4756
var _ PipelinesClient = (*MockPipelinesClient)(nil)
4857

4958
func TestListPipelines(t *testing.T) {
@@ -131,13 +140,14 @@ agents:
131140
queue: "something"
132141
env:
133142
TEST_ENV_VAR: "value"
134-
steps:
143+
steps:
135144
- command: "echo Hello World"
136145
key: "hello_step"
137146
label: "Hello Step"
138147
`
139148

140149
ctx := context.Background()
150+
webhookCalled := false
141151
client := &MockPipelinesClient{
142152
CreateFunc: func(ctx context.Context, org string, p buildkite.CreatePipeline) (buildkite.Pipeline, *buildkite.Response, error) {
143153

@@ -146,7 +156,6 @@ steps:
146156
assert.Equal("cluster-123", p.ClusterID)
147157
assert.Equal("Test Pipeline", p.Name)
148158
assert.Equal("https://example.com/repo.git", p.Repository)
149-
150159
assert.Equal(testPipelineDefinition, p.Configuration)
151160

152161
return buildkite.Pipeline{
@@ -162,6 +171,16 @@ steps:
162171
},
163172
}, nil
164173
},
174+
AddWebhookFunc: func(ctx context.Context, org string, slug string) (*buildkite.Response, error) {
175+
assert.Equal("org", org)
176+
assert.Equal("test-pipeline", slug)
177+
webhookCalled = true
178+
return &buildkite.Response{
179+
Response: &http.Response{
180+
StatusCode: 201,
181+
},
182+
}, nil
183+
},
165184
}
166185

167186
tool, handler, _ := CreatePipeline(client)
@@ -178,12 +197,170 @@ steps:
178197
Description: "A test pipeline",
179198
Configuration: testPipelineDefinition,
180199
Tags: []string{"tag1", "tag2"},
200+
CreateWebhook: nil,
201+
}
202+
203+
result, err := handler(ctx, request, args)
204+
assert.NoError(err)
205+
assert.True(webhookCalled, "AddWebhook should have been called when CreateWebhook is nil")
206+
207+
textContent := getTextResult(t, result)
208+
assert.Contains(textContent.Text, `"webhook":{"created":true,"note":"Pipeline and webhook created successfully."}`)
209+
assert.Contains(textContent.Text, `"pipeline":{"id":"123","name":"Test Pipeline","slug":"test-pipeline"`)
210+
}
211+
212+
func TestCreatePipelineWithWebhook(t *testing.T) {
213+
assert := require.New(t)
214+
215+
testPipelineDefinition := `
216+
agents:
217+
queue: "something"
218+
env:
219+
TEST_ENV_VAR: "value"
220+
steps:
221+
- command: "echo Hello World"
222+
key: "hello_step"
223+
label: "Hello Step"
224+
`
225+
226+
ctx := context.Background()
227+
webhookCalled := false
228+
createWebhook := true
229+
client := &MockPipelinesClient{
230+
CreateFunc: func(ctx context.Context, org string, p buildkite.CreatePipeline) (buildkite.Pipeline, *buildkite.Response, error) {
231+
232+
// validate required fields
233+
assert.Equal("org", org)
234+
assert.Equal("Test Pipeline", p.Name)
235+
assert.Equal("https://github.com/example/repo.git", p.Repository)
236+
assert.Equal("cluster-123", p.ClusterID)
237+
assert.Equal(testPipelineDefinition, p.Configuration)
238+
239+
return buildkite.Pipeline{
240+
ID: "123",
241+
Slug: "test-pipeline",
242+
Name: "Test Pipeline",
243+
ClusterID: "cluster-123",
244+
CreatedAt: &buildkite.Timestamp{},
245+
Tags: []string{"tag1", "tag2"},
246+
}, &buildkite.Response{
247+
Response: &http.Response{
248+
StatusCode: 201,
249+
},
250+
}, nil
251+
},
252+
AddWebhookFunc: func(ctx context.Context, org string, slug string) (*buildkite.Response, error) {
253+
254+
// validate required fields
255+
assert.Equal("org", org)
256+
assert.Equal("test-pipeline", slug)
257+
258+
webhookCalled = true
259+
return &buildkite.Response{
260+
Response: &http.Response{
261+
StatusCode: 201,
262+
},
263+
}, nil
264+
},
265+
}
266+
267+
tool, handler, _ := CreatePipeline(client)
268+
assert.NotNil(tool)
269+
assert.NotNil(handler)
270+
271+
request := createMCPRequest(t, map[string]any{})
272+
273+
args := CreatePipelineArgs{
274+
OrgSlug: "org",
275+
Name: "Test Pipeline",
276+
ClusterID: "cluster-123",
277+
RepositoryURL: "https://github.com/example/repo.git",
278+
Description: "A test pipeline",
279+
Configuration: testPipelineDefinition,
280+
Tags: []string{"tag1", "tag2"},
281+
CreateWebhook: &createWebhook,
181282
}
182283

183284
result, err := handler(ctx, request, args)
184285
assert.NoError(err)
286+
assert.True(webhookCalled, "AddWebhook should have been called")
287+
288+
textContent := getTextResult(t, result)
289+
assert.Contains(textContent.Text, `"webhook":{"created":true,"note":"Pipeline and webhook created successfully."}`)
290+
assert.Contains(textContent.Text, `"pipeline":{"id":"123","name":"Test Pipeline","slug":"test-pipeline"`)
291+
}
292+
293+
func TestCreatePipelineWithWebhookError(t *testing.T) {
294+
assert := require.New(t)
295+
296+
testPipelineDefinition := `
297+
agents:
298+
queue: "something"
299+
env:
300+
TEST_ENV_VAR: "value"
301+
steps:
302+
- command: "echo Hello World"
303+
key: "hello_step"
304+
label: "Hello Step"
305+
`
306+
307+
ctx := context.Background()
308+
webhookCalled := false
309+
createWebhook := true
310+
client := &MockPipelinesClient{
311+
CreateFunc: func(ctx context.Context, org string, p buildkite.CreatePipeline) (buildkite.Pipeline, *buildkite.Response, error) {
312+
313+
// validate required fields
314+
assert.Equal("org", org)
315+
assert.Equal("Test Pipeline", p.Name)
316+
assert.Equal("https://github.com/example/repo.git", p.Repository)
317+
assert.Equal("cluster-123", p.ClusterID)
318+
assert.Equal(testPipelineDefinition, p.Configuration)
319+
320+
return buildkite.Pipeline{
321+
ID: "123",
322+
Slug: "test-pipeline",
323+
Name: "Test Pipeline",
324+
ClusterID: "cluster-123",
325+
CreatedAt: &buildkite.Timestamp{},
326+
Tags: []string{"tag1", "tag2"},
327+
}, &buildkite.Response{
328+
Response: &http.Response{
329+
StatusCode: 201,
330+
},
331+
}, nil
332+
},
333+
AddWebhookFunc: func(ctx context.Context, org string, slug string) (*buildkite.Response, error) {
334+
webhookCalled = true
335+
return nil, errors.New("Auto-creating webhooks is not supported for your repository.")
336+
},
337+
}
338+
339+
tool, handler, _ := CreatePipeline(client)
340+
assert.NotNil(tool)
341+
assert.NotNil(handler)
342+
343+
request := createMCPRequest(t, map[string]any{})
344+
345+
args := CreatePipelineArgs{
346+
OrgSlug: "org",
347+
Name: "Test Pipeline",
348+
ClusterID: "cluster-123",
349+
RepositoryURL: "https://github.com/example/repo.git",
350+
Description: "A test pipeline",
351+
Configuration: testPipelineDefinition,
352+
Tags: []string{"tag1", "tag2"},
353+
CreateWebhook: &createWebhook,
354+
}
355+
356+
result, err := handler(ctx, request, args)
357+
assert.NoError(err)
358+
assert.True(webhookCalled, "AddWebhook should have been called")
359+
185360
textContent := getTextResult(t, result)
186-
assert.Equal(`{"id":"123","name":"Test Pipeline","slug":"test-pipeline","created_at":"0001-01-01T00:00:00Z","skip_queued_branch_builds":false,"cancel_running_branch_builds":false,"cluster_id":"cluster-123","tags":["tag1","tag2"],"provider":{"id":"","webhook_url":"","settings":null}}`, textContent.Text)
361+
assert.Contains(textContent.Text, `"webhook":{"created":false,`)
362+
assert.Contains(textContent.Text, `"error":"Auto-creating webhooks is not supported for your repository."`)
363+
assert.Contains(textContent.Text, `"note":"Pipeline created successfully, but webhook creation failed.`)
187364
}
188365

189366
func TestUpdatePipeline(t *testing.T) {
@@ -193,7 +370,7 @@ func TestUpdatePipeline(t *testing.T) {
193370
queue: "something"
194371
env:
195372
TEST_ENV_VAR: "value"
196-
steps:
373+
steps:
197374
- command: "echo Hello World"
198375
key: "hello_step"
199376
label: "Hello Step"

0 commit comments

Comments
 (0)