Skip to content

Commit d2bc17c

Browse files
JamyDevclaude
andauthored
feat(buildrunner): add Buildkite BuildRunner implementation (#190)
## What Adds `submitqueue/extension/buildrunner/buildkite` — a `BuildRunner` implementation backed by the Buildkite CI platform. The package is four files: - **`buildkite.go`** — `runner` struct implementing `BuildRunner.Trigger`, `.Status`, and `.Cancel` - **`client.go`** — thin HTTP wrapper over the three Buildkite REST endpoints (create/get/cancel build) - **`buildkite_test.go`** — 16 unit tests backed by an in-process `httptest` server - **`BUILD.bazel`** — Bazel build file `Trigger` calls the Buildkite API to create a build, encoding the base and head change URIs as JSON-encoded environment variables (`SQ_BASE_URIS`, `SQ_HEAD_URIS`, `SQ_QUEUE`). The Buildkite pipeline script uses these to fetch diffs via the GitHub API, apply them in order, and run CI. `Status` and `Cancel` derive the Buildkite build number from the `BuildID` string — no local state is needed. Auth and base URL are injected via HTTP transport layers on the caller-provided `*http.Client` (using `httpclient.BaseURLTransport` + an auth transport), following the existing `httpclient` pattern and keeping credentials out of the struct. ## Why The `BuildRunner` extension interface was designed to support multiple CI platforms as pluggable backends. This adds the first real (non-noop) implementation. Buildkite is the target CI platform for speculative merges: its pipeline model maps cleanly onto the base/head separation in `BuildRunner.Trigger` — the pipeline applies the base layer first (potentially cacheable across batches), then the head layer, then runs the full test suite. Using environment variables to pass change URIs keeps the SQ→Buildkite contract simple and auditable; the pipeline script is responsible for materialising the diffs, isolating SQ from Buildkite's pipeline YAML. ## Tests All three interface methods are tested against an in-process `httptest` server — no external dependencies or mocks: - **`Trigger`**: validates HTTP method, request payload (env var keys/values), URI flattening across multiple `Change` entries, and that a nil base produces `[]` (not `null`) in JSON - **`Status`**: state mapping — exhaustive table over all documented Buildkite states (`creating`, `scheduled`, `running`, `blocked`, `passed`, `failed`, `not_run`, `skipped`, `canceling`, `canceled`) plus unknown future states (mapped to non-terminal `Unknown` so the poll loop keeps waiting rather than failing a batch on an unrecognised state); also validates build URL is returned in metadata - **`Cancel`**: verifies the cancel endpoint is called; verifies 422 (already-terminal build) is treated as a no-op - **Helpers**: encode/parse round-trip, invalid build ID rejection --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7d18778 commit d2bc17c

4 files changed

Lines changed: 613 additions & 0 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "buildkite",
5+
srcs = [
6+
"buildkite.go",
7+
"client.go",
8+
],
9+
importpath = "github.com/uber/submitqueue/submitqueue/extension/buildrunner/buildkite",
10+
visibility = ["//visibility:public"],
11+
deps = [
12+
"//submitqueue/entity",
13+
"//submitqueue/extension/buildrunner",
14+
"@org_uber_go_zap//:zap",
15+
],
16+
)
17+
18+
go_test(
19+
name = "buildkite_test",
20+
srcs = ["buildkite_test.go"],
21+
embed = [":buildkite"],
22+
deps = [
23+
"//core/httpclient",
24+
"//submitqueue/entity",
25+
"//submitqueue/extension/buildrunner",
26+
"@com_github_stretchr_testify//assert",
27+
"@com_github_stretchr_testify//require",
28+
"@org_uber_go_zap//:zap",
29+
],
30+
)
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package buildkite implements buildrunner.BuildRunner backed by the Buildkite
16+
// CI platform.
17+
//
18+
// Trigger calls the Buildkite API to create the build and returns the Buildkite
19+
// build number as the build ID. Status and Cancel parse the number directly
20+
// from the build ID — no local state is required.
21+
//
22+
// The Buildkite build receives base and head change URIs as JSON-encoded
23+
// environment variables (SQ_BASE_URIS, SQ_HEAD_URIS, SQ_QUEUE). The pipeline
24+
// script fetches each PR's diff with the GitHub API, applies them with
25+
// `git apply -3`, produces one commit per layer (base, head), then runs CI.
26+
package buildkite
27+
28+
import (
29+
"context"
30+
"encoding/json"
31+
"fmt"
32+
"net/http"
33+
"strconv"
34+
35+
"go.uber.org/zap"
36+
37+
"github.com/uber/submitqueue/submitqueue/entity"
38+
"github.com/uber/submitqueue/submitqueue/extension/buildrunner"
39+
)
40+
41+
// Env var keys set on every triggered Buildkite build.
42+
const (
43+
// EnvKeyBaseURIs carries the JSON-encoded ordered list of change URIs from
44+
// the dependency batches. The pipeline script applies these first and
45+
// commits the result as the "base" layer.
46+
EnvKeyBaseURIs = "SQ_BASE_URIS"
47+
48+
// EnvKeyHeadURIs carries the JSON-encoded ordered list of change URIs from
49+
// the batch under test. Applied on top of the base layer, committed
50+
// separately.
51+
EnvKeyHeadURIs = "SQ_HEAD_URIS"
52+
53+
// EnvKeyQueue carries the SQ queue name so the pipeline script can select
54+
// queue-specific test targets.
55+
EnvKeyQueue = "SQ_QUEUE"
56+
)
57+
58+
// runner implements buildrunner.BuildRunner.
59+
type runner struct {
60+
cfg buildrunner.Config
61+
client *client
62+
logger *zap.SugaredLogger
63+
}
64+
65+
var _ buildrunner.BuildRunner = (*runner)(nil)
66+
67+
// Params holds the dependencies for a Buildkite BuildRunner. The caller is
68+
// responsible for configuring HTTPClient with the base URL (via
69+
// httpclient.BaseURLTransport) and auth (via an Authorization-header transport).
70+
type Params struct {
71+
// Config holds the per-queue identity for this BuildRunner.
72+
Config buildrunner.Config
73+
// HTTPClient is a pre-configured HTTP client. The caller is responsible
74+
// for the base URL (via httpclient.BaseURLTransport) and auth (via a
75+
// transport layer). If nil, http.DefaultClient is used.
76+
HTTPClient *http.Client
77+
// Logger is the structured logger.
78+
Logger *zap.SugaredLogger
79+
}
80+
81+
// NewBuildRunner constructs a Buildkite-backed BuildRunner bound to a single
82+
// pipeline.
83+
//
84+
// The HTTPClient must have BaseURLTransport configured to the pipeline's API
85+
// root (e.g. "https://api.buildkite.com/v2/organizations/{org}/pipelines/{slug}"),
86+
// and an auth transport that injects the Authorization header.
87+
func NewBuildRunner(params Params) (buildrunner.BuildRunner, error) {
88+
if params.HTTPClient == nil {
89+
return nil, fmt.Errorf("http client is required")
90+
}
91+
if params.Logger == nil {
92+
return nil, fmt.Errorf("logger is required")
93+
}
94+
return newRunner(params.Config, &client{httpClient: params.HTTPClient}, params.Logger.Named("buildkite_buildrunner")), nil
95+
}
96+
97+
// newRunner constructs a runner. Used by NewBuildRunner and by tests.
98+
func newRunner(cfg buildrunner.Config, c *client, logger *zap.SugaredLogger) *runner {
99+
return &runner{
100+
cfg: cfg,
101+
client: c,
102+
logger: logger,
103+
}
104+
}
105+
106+
// Trigger calls the Buildkite API to create the build and returns the Buildkite
107+
// build number as the build ID. Errors are propagated to the caller so the
108+
// queue consumer can nack and retry.
109+
func (r *runner) Trigger(ctx context.Context, base, head []entity.Change, _ entity.BuildMetadata) (entity.BuildID, error) {
110+
baseJSON, _ := json.Marshal(flattenURIs(base))
111+
headJSON, _ := json.Marshal(flattenURIs(head))
112+
113+
req := createBuildRequest{
114+
Message: "submitqueue speculative build",
115+
Env: map[string]string{
116+
EnvKeyBaseURIs: string(baseJSON),
117+
EnvKeyHeadURIs: string(headJSON),
118+
EnvKeyQueue: r.cfg.QueueName,
119+
},
120+
}
121+
122+
resp, err := r.client.createBuild(ctx, req)
123+
if err != nil {
124+
return entity.BuildID{}, fmt.Errorf("buildkite: create build: %w", err)
125+
}
126+
127+
r.logger.Debugw("triggered Buildkite build",
128+
"buildkite_number", resp.Number,
129+
)
130+
return entity.BuildID{ID: encodeBuildNumber(resp.Number)}, nil
131+
}
132+
133+
// Status fetches the current state of the build from Buildkite and returns it
134+
// with the build URL in BuildMetadata["url"].
135+
func (r *runner) Status(ctx context.Context, buildID entity.BuildID) (entity.BuildStatus, entity.BuildMetadata, error) {
136+
number, err := parseBuildNumber(buildID.ID)
137+
if err != nil {
138+
return entity.BuildStatusUnknown, nil, fmt.Errorf("buildkite: malformed build ID: %w", err)
139+
}
140+
141+
resp, err := r.client.getBuild(ctx, number)
142+
if err != nil {
143+
return entity.BuildStatusUnknown, nil, fmt.Errorf("buildkite: get build: %w", err)
144+
}
145+
146+
return mapState(resp.State), entity.BuildMetadata{"url": resp.WebURL}, nil
147+
}
148+
149+
// Cancel calls the Buildkite API to cancel the build. A no-op on already-terminal
150+
// builds (Buildkite returns 422 for those).
151+
func (r *runner) Cancel(ctx context.Context, buildID entity.BuildID) error {
152+
number, err := parseBuildNumber(buildID.ID)
153+
if err != nil {
154+
return fmt.Errorf("buildkite: malformed build ID: %w", err)
155+
}
156+
157+
if err := r.client.cancelBuild(ctx, number); err != nil {
158+
return fmt.Errorf("buildkite: cancel build: %w", err)
159+
}
160+
r.logger.Debugw("cancelled Buildkite build",
161+
"buildkite_number", number,
162+
)
163+
return nil
164+
}
165+
166+
// flattenURIs concatenates the URI lists from all changes into a single slice.
167+
func flattenURIs(changes []entity.Change) []string {
168+
uris := make([]string, 0, len(changes))
169+
for _, c := range changes {
170+
uris = append(uris, c.URIs...)
171+
}
172+
return uris
173+
}
174+
175+
// encodeBuildNumber encodes a Buildkite build number as the SQ build ID.
176+
func encodeBuildNumber(number int) string {
177+
return strconv.Itoa(number)
178+
}
179+
180+
// parseBuildNumber is the inverse of encodeBuildNumber.
181+
func parseBuildNumber(id string) (int, error) {
182+
n, err := strconv.Atoi(id)
183+
if err != nil {
184+
return 0, fmt.Errorf("invalid build ID %q", id)
185+
}
186+
return n, nil
187+
}
188+
189+
// mapState maps a Buildkite build state string to a BuildStatus.
190+
//
191+
// Buildkite states: creating, scheduled, running, blocked, passed, failed,
192+
// canceling, canceled, skipped, not_run.
193+
func mapState(state string) entity.BuildStatus {
194+
switch state {
195+
case "creating", "scheduled":
196+
return entity.BuildStatusAccepted
197+
case "running", "blocked":
198+
// blocked = waiting on a block step; still live, not yet terminal.
199+
return entity.BuildStatusRunning
200+
case "passed":
201+
return entity.BuildStatusSucceeded
202+
case "failed", "not_run", "skipped":
203+
// not_run/skipped never produced a passing result; treat them as
204+
// terminal failure so the batch is not merged on a non-success verdict.
205+
return entity.BuildStatusFailed
206+
case "canceling", "canceled":
207+
return entity.BuildStatusCancelled
208+
default:
209+
// Unrecognised Buildkite state. Do NOT assume terminal: Unknown is
210+
// non-terminal, so the buildsignal poll loop keeps waiting rather than
211+
// failing the batch on a state this code does not understand.
212+
return entity.BuildStatusUnknown
213+
}
214+
}

0 commit comments

Comments
 (0)