Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Cross-domain packages shared by SubmitQueue, Stovepipe, and other services in th
- **pipeline/** — Typed engine that assembles a service's queue topology into one lifecycle component.
- **publish/** — Topic-key publish plumbing and the message-ID convention that controls deduplication.
- **buildkite/**, **githubactions/** — Vendor CI clients over `platform/http`: the REST calls and provider-specific vocabulary (state strings, id encoding) shared by every domain's `BuildRunner` backend. They deliberately define no interface, so they are plumbing rather than extensions — each domain keeps its own `BuildRunner` contract under `{domain}/extension/buildrunner` and adapts these clients to it.
- **githubapi/** — Request conventions every direct GitHub API caller shares: the REST headers pinned to one API version, and the GraphQL envelope with its errors. Not a client: callers keep their own `*http.Client` and typed requests.
- **base/** — Shared domain entities (`change`, `messagequeue`, and related subpackages). Root package `base` is documentation-only.
- **extension/** — Shared extension interfaces and implementations reused across domains (`counter`, `messagequeue`, and backends such as `mysql`). A package belongs here only if it defines a behavioral interface with its `Config` and `Factory` interface; a vendor client with no interface belongs directly under `platform/`.

Expand Down
5 changes: 4 additions & 1 deletion platform/githubactions/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ go_library(
srcs = ["client.go"],
importpath = "github.com/uber/submitqueue/platform/githubactions",
visibility = ["//visibility:public"],
deps = ["//platform/http:go_default_library"],
deps = [
"//platform/githubapi:go_default_library",
"//platform/http:go_default_library",
],
)

go_test(
Expand Down
13 changes: 3 additions & 10 deletions platform/githubactions/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,10 @@ import (
"net/url"
"strconv"

"github.com/uber/submitqueue/platform/githubapi"
phttp "github.com/uber/submitqueue/platform/http"
)

const githubAPIVersion = "2026-03-10"

// ErrNotFound is returned when the GitHub Actions API responds with 404 to a
// request for a resource by ID (e.g. GetRun on an unknown run, or CancelRun
// on an already-deleted run).
Expand Down Expand Up @@ -136,7 +135,7 @@ func (c *Client) GetRun(ctx context.Context, runID int64) (WorkflowRun, error) {
// run is already terminal or otherwise not cancellable (HTTP 409/422) — the
// BuildRunner contract treats that as a no-op.
func (c *Client) CancelRun(ctx context.Context, runID int64) error {
status, _, err := phttp.SendRequest(ctx, c.httpClient, http.MethodPost, c.runPath(runID)+"/cancel", nil, c.setHeaders)
status, _, err := phttp.SendRequest(ctx, c.httpClient, http.MethodPost, c.runPath(runID)+"/cancel", nil, githubapi.SetRESTHeaders)
if err != nil {
return err
}
Expand Down Expand Up @@ -181,7 +180,7 @@ func (c *Client) runPath(runID int64) string {
// 2xx response, decodes the body into out (when non-nil). A 404 is reported
// as ErrNotFound so callers can distinguish it from other failures.
func (c *Client) do(ctx context.Context, method, rawURL string, body []byte, out any) error {
status, respBody, err := phttp.SendRequest(ctx, c.httpClient, method, rawURL, body, c.setHeaders)
status, respBody, err := phttp.SendRequest(ctx, c.httpClient, method, rawURL, body, githubapi.SetRESTHeaders)
if err != nil {
return err
}
Expand All @@ -203,12 +202,6 @@ func (c *Client) do(ctx context.Context, method, rawURL string, body []byte, out
return nil
}

func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-GitHub-Api-Version", githubAPIVersion)
}

// EncodeRunID encodes a GitHub Actions workflow run id as an opaque build id
// string.
func EncodeRunID(runID int64) string {
Expand Down
20 changes: 20 additions & 0 deletions platform/githubapi/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "go_default_library",
srcs = ["githubapi.go"],
importpath = "github.com/uber/submitqueue/platform/githubapi",
visibility = ["//visibility:public"],
deps = ["//platform/http:go_default_library"],
)

go_test(
name = "go_default_test",
srcs = ["githubapi_test.go"],
embed = [":go_default_library"],
deps = [
"//platform/http:go_default_library",
"@com_github_stretchr_testify//assert:go_default_library",
"@com_github_stretchr_testify//require:go_default_library",
],
)
14 changes: 14 additions & 0 deletions platform/githubapi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# GitHub API conventions

The parts of a GitHub API request that every direct caller would otherwise repeat:

- `SetRESTHeaders` sets the JSON media type and pins the REST API version (`Version`), so every REST caller moves to a new version together. Pass it as `platform/http.SendRequest`'s `setHeaders`.
- `QueryGraphQL` posts a `GraphQLRequest` to `/graphql` and decodes the response's `data` into the caller's type. A non-200 reply is a `platform/http.StatusError`, so `platform/errs/http` classifies it like any other HTTP failure; a 200 reply that carries GraphQL errors fails even when it also carries data.

This package is not a client. Each caller keeps its own `*http.Client` (API root from `platform/http.BaseURLTransport`, auth from a transport) and its own typed requests and responses, and decides what a missing resource means for it.

## Who uses it

- `platform/githubactions` and `runway/extension/merger/github` (REST)
- `submitqueue/extension/changeprovider/github` and `submitqueue/extension/validator/github` (GraphQL)
- `test/testutil/githubtestrepo` (REST)
111 changes: 111 additions & 0 deletions platform/githubapi/githubapi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package githubapi holds the request conventions shared by every component
// that calls the GitHub API directly: the REST headers, pinned to one API
// version, and the GraphQL request and response envelope.
//
// It is not a client. Callers keep their own *http.Client, with the API root
// resolved by platform/http.BaseURLTransport and auth set by a transport, and
// their own typed requests; this package only removes the parts every caller
// would otherwise repeat.
package githubapi

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"

phttp "github.com/uber/submitqueue/platform/http"
)

// Version is the GitHub REST API version every REST request pins.
const Version = "2026-03-10"

// SetRESTHeaders sets the headers of a JSON request to the GitHub REST API. It
// has the signature platform/http.SendRequest takes for its setHeaders.
func SetRESTHeaders(req *http.Request) {
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-GitHub-Api-Version", Version)
}

// GraphQLRequest is the body of a request to the GitHub GraphQL API.
type GraphQLRequest struct {
// Query is the GraphQL document.
Query string `json:"query"`
// Variables binds the query's variables by name.
Variables map[string]any `json:"variables"`
}

type graphQLResponse struct {
Data json.RawMessage `json:"data"`
Errors graphQLErrors `json:"errors"`
}

type graphQLError struct {
Type string `json:"type"`
Message string `json:"message"`
}

// graphQLErrors is the errors array of a response GitHub answered with 200.
type graphQLErrors []graphQLError

func (e graphQLErrors) Error() string {
msgs := make([]string, len(e))
for i, err := range e {
msgs[i] = err.Type + ": " + err.Message
}
return "GraphQL errors: " + strings.Join(msgs, "; ")
}

// QueryGraphQL posts req to the client's /graphql and decodes the response's
// data into data. A non-200 reply is a *platform/http.StatusError, so error
// classifiers can read its code; a 200 reply that carries errors fails even
// when it also carries data. A null data leaves data untouched.
func QueryGraphQL(ctx context.Context, client *http.Client, req GraphQLRequest, data any) error {
body, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal GraphQL request: %w", err)
}
// The path is relative: the client's transport roots it at the API.
status, respBody, err := phttp.SendRequest(ctx, client, http.MethodPost, "/graphql", body, setGraphQLHeaders)
if err != nil {
return err
}
if status != http.StatusOK {
return phttp.NewStatusError(status, respBody)
}

var resp graphQLResponse
if err := json.Unmarshal(respBody, &resp); err != nil {
return fmt.Errorf("decode GraphQL response: %w", err)
}
if len(resp.Errors) > 0 {
return resp.Errors
}
if len(resp.Data) == 0 {
return nil
}
if err := json.Unmarshal(resp.Data, data); err != nil {
return fmt.Errorf("decode GraphQL data: %w", err)
}
return nil
}

func setGraphQLHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
}
154 changes: 154 additions & 0 deletions platform/githubapi/githubapi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package githubapi

import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

phttp "github.com/uber/submitqueue/platform/http"
)

func TestSetRESTHeaders(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/repos/o/r", nil)
require.NoError(t, err)

SetRESTHeaders(req)

assert.Equal(t, "application/vnd.github+json", req.Header.Get("Accept"))
assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
assert.Equal(t, Version, req.Header.Get("X-GitHub-Api-Version"))
}

type pullRequestData struct {
Repository struct {
PullRequest struct {
HeadRefOid string `json:"headRefOid"`
} `json:"pullRequest"`
} `json:"repository"`
}

func TestQueryGraphQL(t *testing.T) {
tests := []struct {
name string
status int
body string
wantHead string
wantErr bool
wantStatus int
wantGraphQLErr bool
}{
{
name: "decodes data",
status: http.StatusOK,
body: `{"data":{"repository":{"pullRequest":{"headRefOid":"abc"}}}}`,
wantHead: "abc",
},
{
name: "null data leaves the result untouched",
status: http.StatusOK,
body: `{"data":null}`,
},
{
name: "missing data leaves the result untouched",
status: http.StatusOK,
body: `{}`,
},
{
name: "non-200 status is a status error",
status: http.StatusBadGateway,
body: `upstream unavailable`,
wantErr: true,
wantStatus: http.StatusBadGateway,
},
{
name: "401 is a status error",
status: http.StatusUnauthorized,
body: `{"message":"Bad credentials"}`,
wantErr: true,
wantStatus: http.StatusUnauthorized,
},
{
name: "undecodable body",
status: http.StatusOK,
body: `not json`,
wantErr: true,
},
{
name: "GraphQL errors",
status: http.StatusOK,
body: `{"errors":[{"type":"NOT_FOUND","message":"Could not resolve to a Repository"},{"type":"FORBIDDEN","message":"Resource not accessible"}]}`,
wantErr: true,
wantGraphQLErr: true,
},
{
name: "GraphQL errors alongside data",
status: http.StatusOK,
body: `{"data":{"repository":{"pullRequest":null}},"errors":[{"type":"NOT_FOUND","message":"Could not resolve to a PullRequest"}]}`,
wantErr: true,
wantGraphQLErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotMethod, gotPath, gotContentType string
var gotReq GraphQLRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod, gotPath, gotContentType = r.Method, r.URL.Path, r.Header.Get("Content-Type")
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &gotReq)
w.WriteHeader(tt.status)
_, _ = io.WriteString(w, tt.body)
}))
t.Cleanup(server.Close)
client, err := phttp.NewClient(server.URL)
require.NoError(t, err)

req := GraphQLRequest{Query: "query { viewer { login } }", Variables: map[string]any{"number": float64(7)}}
var data pullRequestData
err = QueryGraphQL(context.Background(), client, req, &data)

assert.Equal(t, http.MethodPost, gotMethod)
assert.Equal(t, "/graphql", gotPath)
assert.Equal(t, "application/json", gotContentType)
assert.Equal(t, req, gotReq)

var statusErr *phttp.StatusError
var gqlErr graphQLErrors
if !tt.wantErr {
require.NoError(t, err)
assert.Equal(t, tt.wantHead, data.Repository.PullRequest.HeadRefOid)
return
}
require.Error(t, err)
if tt.wantStatus != 0 {
require.True(t, errors.As(err, &statusErr))
assert.Equal(t, tt.wantStatus, statusErr.StatusCode)
} else {
assert.False(t, errors.As(err, &statusErr))
}
assert.Equal(t, tt.wantGraphQLErr, errors.As(err, &gqlErr))
})
}
}
1 change: 1 addition & 0 deletions runway/extension/merger/github/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ go_library(
"//api/runway/messagequeue/protopb:go_default_library",
"//platform/base/change/github:go_default_library",
"//platform/errs:go_default_library",
"//platform/githubapi:go_default_library",
"//platform/http:go_default_library",
"//platform/metrics:go_default_library",
"//runway/extension/merger:go_default_library",
Expand Down
Loading