From 9e066a97ad7a1e642fbaaf74311c7970efc2d389 Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Wed, 7 Oct 2026 13:29:03 -0700 Subject: [PATCH] test(submitqueue): land real GitHub PRs in CI e2e and integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ### Why? The merger's unit tests use a fake, which only encodes our reading of GitHub's docs. These suites check the merger, and SubmitQueue's whole land path, against real github.com on every CI run. ### What? - `TestGitHubLandE2E`: gateway → orchestrator (GitHub change provider) → Runway (GitHub merger). It lands a single PR and a stack, and rejects PRs that are not a stack. - Merger integration test: a full stack with redelivery, a partial stack, non-stack lists, and a moved head. - Shared `test/testutil/githubtestrepo` fixtures, which skip unless `SQ_GITHUB_TOKEN` and `SQ_GITHUB_TEST_REPO` are set. - CI: the `e2e` job and a new `merger-integration-test` job take the test repo from the `SQ_GITHUB_TEST_REPO` repository variable and its token from the `SQ_TEST_REPO_TOKEN` repository secret. The secrets guard allows it as the only exception. - `e2e-github-queue` is added to `queues.yaml` and to `MQ_TENANTS`. - Any contributor can use their own test repo, locally or in their fork's CI; `TESTING.md` lists what it needs. CI requires the token only where `SQ_GITHUB_TEST_REPO` is set. ## Test Plan ✅ `TestGitHubLandE2E` 3/3 and the merger integration test 4/4 against `behinddwalls/sq-demo`; no fixtures left behind ✅ `make lint check-gazelle check-tidy` --- .github/workflows/ci.yml | 46 ++- Makefile | 4 + doc/howto/TESTING.md | 21 + runway/extension/merger/github/README.md | 9 + service/submitqueue/BUILD.bazel | 1 + service/submitqueue/docker-compose.yml | 6 +- .../gateway/server/docker-compose.yml | 2 +- .../submitqueue/gateway/server/queues.yaml | 4 + test/e2e/submitqueue/BUILD.bazel | 10 + test/e2e/submitqueue/github_suite_test.go | 198 ++++++++++ .../extension/merger/github/BUILD.bazel | 32 ++ .../extension/merger/github/github_test.go | 162 ++++++++ .../submitqueue/gateway/suite_test.go | 1 + test/testutil/githubtestrepo/BUILD.bazel | 13 + .../testutil/githubtestrepo/githubtestrepo.go | 360 ++++++++++++++++++ 15 files changed, 864 insertions(+), 5 deletions(-) create mode 100644 test/e2e/submitqueue/github_suite_test.go create mode 100644 test/integration/runway/extension/merger/github/BUILD.bazel create mode 100644 test/integration/runway/extension/merger/github/github_test.go create mode 100644 test/testutil/githubtestrepo/BUILD.bazel create mode 100644 test/testutil/githubtestrepo/githubtestrepo.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6c17826e..7034316e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,15 @@ jobs: - uses: ./.github/actions/setup - name: Run E2E tests + env: + # Lands real pull requests in the test repository; see the secrets + # guard in workflow-security. + SQ_GITHUB_TOKEN: ${{ secrets.SQ_TEST_REPO_TOKEN }} + SQ_GITHUB_TEST_REPO: ${{ vars.SQ_GITHUB_TEST_REPO }} + # Fail rather than skip when the secret is missing, but only in a + # repository that set up a test repo (forks without one skip), and + # not on fork PRs, which GitHub never gives secrets to. + SQ_GITHUB_TEST_REQUIRED: ${{ vars.SQ_GITHUB_TEST_REPO != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} run: make e2e-test - name: Upload Bazel failure logs @@ -197,6 +206,34 @@ jobs: with: target: //test/integration/extension/messagequeue/... + merger-integration-test: + name: Merger Extension Test + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + # This job executes untrusted PR code (make build/test/lint). Don't + # leave the GITHUB_TOKEN in the workspace git config while it runs. + persist-credentials: false + - uses: ./.github/actions/setup + + - name: Run merger extension tests + env: + # Lands real pull requests in the test repository; see the secrets + # guard in workflow-security. + SQ_GITHUB_TOKEN: ${{ secrets.SQ_TEST_REPO_TOKEN }} + SQ_GITHUB_TEST_REPO: ${{ vars.SQ_GITHUB_TEST_REPO }} + # Fail rather than skip when the secret is missing, but only in a + # repository that set up a test repo (forks without one skip), and + # not on fork PRs, which GitHub never gives secrets to. + SQ_GITHUB_TEST_REQUIRED: ${{ vars.SQ_GITHUB_TEST_REPO != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} + run: make integration-test-runway-merger + + - name: Upload Bazel failure logs + if: ${{ failure() }} + uses: ./.github/actions/upload-testlogs + storage-integration-test: name: Storage Extension Test if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} @@ -272,11 +309,17 @@ jobs: # real defenses remain (a) secrets scoped to a main-only Environment so a # PR-triggered job cannot obtain them, and (b) CODEOWNERS review on # .github/. GITHUB_TOKEN (least-privilege, read-only here) is allowlisted. + # + # SQ_TEST_REPO_TOKEN is the one deliberate exception. The live GitHub + # tests (e2e and merger extension) merge real pull requests in a separate + # test repository, which GITHUB_TOKEN cannot reach. It is a fine-grained + # token limited to that repository, so a PR that exfiltrated it could only + # reach that repository. - name: Guard — no repository secrets on the untrusted-code path run: | hits="$(grep -rnE '\$\{\{[^}]*secrets\.' \ .github/workflows/ci.yml .github/actions \ - | grep -vE 'secrets\.GITHUB_TOKEN' || true)" + | grep -vE 'secrets\.(GITHUB_TOKEN|SQ_TEST_REPO_TOKEN)' || true)" if [ -n "$hits" ]; then echo "::error::Repository secret referenced on the untrusted-code CI path (ci.yml / composite actions):" >&2 echo "$hits" >&2 @@ -308,6 +351,7 @@ jobs: - orchestrator-integration-test - counter-integration-test - queue-integration-test + - merger-integration-test - storage-integration-test - consumer-integration-test - workflow-security diff --git a/Makefile b/Makefile index bee58f3a5..8dfd0f0b4 100644 --- a/Makefile +++ b/Makefile @@ -301,6 +301,10 @@ integration-test-extensions: ## Run extension integration tests (runs in paralle @echo "Running extension integration tests (parallel)..." @$(BAZEL) test //test/integration/submitqueue/extension/... //test/integration/extension/... --test_output=errors +integration-test-runway-merger: ## Run Runway merger extension tests (GitHub ones need SQ_GITHUB_TOKEN and SQ_GITHUB_TEST_REPO=owner/repo, else skip) + @echo "Running Runway merger extension tests..." + @$(BAZEL) test //test/integration/runway/... --test_output=errors + integration-test-submitqueue-gateway: ## Run Gateway integration tests @echo "Running Gateway integration tests..." @$(BAZEL) test //test/integration/submitqueue/gateway:go_default_test --test_output=streamed diff --git a/doc/howto/TESTING.md b/doc/howto/TESTING.md index 8f0f33630..167e5bc2c 100644 --- a/doc/howto/TESTING.md +++ b/doc/howto/TESTING.md @@ -84,6 +84,27 @@ make build-all-linux # Build Linux binaries for the local docker- - Containers: Each suite's required services and dependencies; SubmitQueue E2E includes Gateway, Orchestrator, Runway, and MySQL - Tests end-to-end behavior, including cross-service communication where applicable +### Live GitHub Tests + +Two suites land real pull requests on github.com: `TestGitHubLandE2E` (in `make e2e-test`) and the Runway GitHub merger extension test (`make integration-test-runway-merger`). They run only when `SQ_GITHUB_TOKEN` and `SQ_GITHUB_TEST_REPO=owner/repo` are set, and skip otherwise, so every other run is unaffected. The token needs write access to the test repository; everything the tests open is named under `sq-it/` and removed afterwards, while what they merge stays on its default branch. + +```bash +SQ_GITHUB_TOKEN=$(gh auth token) SQ_GITHUB_TEST_REPO=/ make e2e-test +``` + +CI takes the repository from the `SQ_GITHUB_TEST_REPO` repository variable and the token from the `SQ_TEST_REPO_TOKEN` repository secret. In a repository that sets the variable, every CI run except a fork pull request sets `SQ_GITHUB_TEST_REQUIRED=true`, which turns a missing secret into a failure; an expired or revoked token fails regardless. Fork pull requests receive no secrets, and repositories without the variable skip the suites. + +#### Use your own test repository + +Any contributor can run these suites against a repository of their own: + +1. Create a repository on github.com with a default branch (an initial commit is enough). It must allow squash and rebase merges, and its default branch must not require reviews or status checks, since the tests merge directly. +2. Create a token that can write to it: a fine-grained token limited to that repository with read and write access to contents, pull requests and issues, or simply `gh auth token`. +3. Run locally with `SQ_GITHUB_TOKEN` and `SQ_GITHUB_TEST_REPO` set, as above. +4. For CI in your fork, set the `SQ_GITHUB_TEST_REPO` repository variable and the `SQ_TEST_REPO_TOKEN` repository secret in the fork's Actions settings. Runs in the fork then exercise your test repository. + +Every run merges a few small files under `sq-it/` into the test repository's default branch, so use a repository that exists for this. + ### How Automated Tests Work Tests use **docker-compose** via `ComposeStack` to spin up containers automatically: diff --git a/runway/extension/merger/github/README.md b/runway/extension/merger/github/README.md index 17efa02af..13727a2bd 100644 --- a/runway/extension/merger/github/README.md +++ b/runway/extension/merger/github/README.md @@ -12,6 +12,15 @@ Each step's strategy maps onto a GitHub merge method — `REBASE` to `rebase`, ` Each URI's output is the merge commit GitHub records for its pull request: the squash commit, the merge commit, or, for a rebase, the last commit the rebase created for that pull request. That is one output per URI, where the git merger reports one per created commit under `REBASE`. It is read from the pull request's `merged` issue event, because API version 2026-03-10 no longer reports `merge_commit_sha` on a merged pull request. GitHub reports a stack merge settled a moment before every pull request in it shows its merge, so the merger re-reads until each is recorded. +## Live tests + +Two suites run against a real repository whenever `SQ_GITHUB_TOKEN` and `SQ_GITHUB_TEST_REPO=owner/repo` are set, and skip otherwise (see [`test/testutil/githubtestrepo`](../../../../test/testutil/githubtestrepo)). Both open, stack and merge throwaway pull requests and check what GitHub recorded: + +- [`test/integration/runway/extension/merger/github`](../../../../test/integration/runway/extension/merger/github) drives this merger on its own (`make integration-test-runway-merger`). +- `TestGitHubLandE2E` in [`test/e2e/submitqueue`](../../../../test/e2e/submitqueue) lands pull requests through the whole stack — gateway, orchestrator with the GitHub change provider, and Runway with this merger (`make e2e-test`). + +CI runs both in its usual e2e and merger extension jobs, with the token from the `SQ_TEST_REPO_TOKEN` repository secret. + ## What a step must be The URIs of a step must be something GitHub will land as one stack onto the target, and anything else is refused as an invalid request: diff --git a/service/submitqueue/BUILD.bazel b/service/submitqueue/BUILD.bazel index 87007b302..710069cd1 100644 --- a/service/submitqueue/BUILD.bazel +++ b/service/submitqueue/BUILD.bazel @@ -1,6 +1,7 @@ exports_files( [ "docker-compose.git.yml", + "docker-compose.provider.yml", "docker-compose.yml", ], visibility = ["//visibility:public"], diff --git a/service/submitqueue/docker-compose.yml b/service/submitqueue/docker-compose.yml index 3055854cd..ef1a094e4 100644 --- a/service/submitqueue/docker-compose.yml +++ b/service/submitqueue/docker-compose.yml @@ -77,7 +77,7 @@ services: # Level for the queue's own logs; info by default so its per-message # chatter does not bury the rest of the service at debug. - QUEUE_LOG_LEVEL=${QUEUE_LOG_LEVEL:-} - - MQ_TENANTS=${MQ_TENANTS:-test-queue,e2e-test-queue,e2e-cancel-queue,e2e-chain-queue,e2e-redelivery-queue,e2e-strand-queue,e2e-conflict-error-queue,e2e-git-queue,demo-queue,e2e-respeculate-queue,file-overlap-queue} + - MQ_TENANTS=${MQ_TENANTS:-test-queue,e2e-test-queue,e2e-cancel-queue,e2e-chain-queue,e2e-redelivery-queue,e2e-strand-queue,e2e-conflict-error-queue,e2e-git-queue,e2e-github-queue,demo-queue,e2e-respeculate-queue,file-overlap-queue} # Path to YAML queue configuration baked into the image - QUEUE_CONFIG_PATH=/app/queues.yaml # Stable subscriber name for the request-log consumer @@ -110,7 +110,7 @@ services: # Level for the queue's own logs; info by default so its per-message # chatter does not bury the rest of the service at debug. - QUEUE_LOG_LEVEL=${QUEUE_LOG_LEVEL:-} - - MQ_TENANTS=${MQ_TENANTS:-test-queue,e2e-test-queue,e2e-cancel-queue,e2e-chain-queue,e2e-redelivery-queue,e2e-strand-queue,e2e-conflict-error-queue,e2e-git-queue,demo-queue,e2e-respeculate-queue,file-overlap-queue} + - MQ_TENANTS=${MQ_TENANTS:-test-queue,e2e-test-queue,e2e-cancel-queue,e2e-chain-queue,e2e-redelivery-queue,e2e-strand-queue,e2e-conflict-error-queue,e2e-git-queue,e2e-github-queue,demo-queue,e2e-respeculate-queue,file-overlap-queue} - HOSTNAME=orchestrator-dev # Consumer-gate state shared with the host (see header comment) - CONSUMER_GATE_DIR=/var/submitqueue/consumergate @@ -143,7 +143,7 @@ services: # Level for the queue's own logs; info by default so its per-message # chatter does not bury the rest of the service at debug. - QUEUE_LOG_LEVEL=${QUEUE_LOG_LEVEL:-} - - MQ_TENANTS=${MQ_TENANTS:-test-queue,e2e-test-queue,e2e-cancel-queue,e2e-chain-queue,e2e-redelivery-queue,e2e-strand-queue,e2e-conflict-error-queue,e2e-git-queue,demo-queue,e2e-respeculate-queue,file-overlap-queue} + - MQ_TENANTS=${MQ_TENANTS:-test-queue,e2e-test-queue,e2e-cancel-queue,e2e-chain-queue,e2e-redelivery-queue,e2e-strand-queue,e2e-conflict-error-queue,e2e-git-queue,e2e-github-queue,demo-queue,e2e-respeculate-queue,file-overlap-queue} - HOSTNAME=runway-dev # Consumer-gate state shared with the host (see header comment) - CONSUMER_GATE_DIR=/var/submitqueue/consumergate diff --git a/service/submitqueue/gateway/server/docker-compose.yml b/service/submitqueue/gateway/server/docker-compose.yml index 7cda0e689..d44b1233b 100644 --- a/service/submitqueue/gateway/server/docker-compose.yml +++ b/service/submitqueue/gateway/server/docker-compose.yml @@ -66,7 +66,7 @@ services: # Level for the queue's own logs; info by default so its per-message # chatter does not bury the rest of the service at debug. - QUEUE_LOG_LEVEL=${QUEUE_LOG_LEVEL:-} - - MQ_TENANTS=${MQ_TENANTS:-test-queue,e2e-test-queue,e2e-cancel-queue,e2e-chain-queue,e2e-redelivery-queue,e2e-strand-queue,e2e-conflict-error-queue,e2e-git-queue,demo-queue,e2e-respeculate-queue,file-overlap-queue} + - MQ_TENANTS=${MQ_TENANTS:-test-queue,e2e-test-queue,e2e-cancel-queue,e2e-chain-queue,e2e-redelivery-queue,e2e-strand-queue,e2e-conflict-error-queue,e2e-git-queue,e2e-github-queue,demo-queue,e2e-respeculate-queue,file-overlap-queue} # Path to YAML queue configuration baked into the image - QUEUE_CONFIG_PATH=/app/queues.yaml # Stable subscriber name for the request-log consumer diff --git a/service/submitqueue/gateway/server/queues.yaml b/service/submitqueue/gateway/server/queues.yaml index 70f07bf46..8a292266b 100644 --- a/service/submitqueue/gateway/server/queues.yaml +++ b/service/submitqueue/gateway/server/queues.yaml @@ -24,6 +24,10 @@ queues: # Used by the hermetic git E2E, where Runway is wired to a real git merger # against a bare repository. See service/submitqueue/demo/provider/git. - name: e2e-git-queue + # Used by the GitHub E2E, which lands real pull requests in a test + # repository through the GitHub API. Its provider configuration is generated + # by the test (test/e2e/submitqueue/github_suite_test.go). + - name: e2e-github-queue # Used by the provider demo stack (make local-submitqueue-start) in every mode — # fake, git, and github. See service/submitqueue/demo/provider and # doc/howto/QUICKSTART.md. diff --git a/test/e2e/submitqueue/BUILD.bazel b/test/e2e/submitqueue/BUILD.bazel index cbbe11849..d56e86138 100644 --- a/test/e2e/submitqueue/BUILD.bazel +++ b/test/e2e/submitqueue/BUILD.bazel @@ -5,6 +5,7 @@ go_test( srcs = [ "fake_demo_test.go", "git_suite_test.go", + "github_suite_test.go", "harness_test.go", "suite_test.go", ], @@ -13,6 +14,7 @@ go_test( "//platform/extension/messagequeue/mysql/schema", "//service/runway/server:docker_test_context", "//service/submitqueue:docker-compose.git.yml", + "//service/submitqueue:docker-compose.provider.yml", "//service/submitqueue:docker-compose.yml", "//service/submitqueue/demo/provider/git:config", "//service/submitqueue/demo/requests", @@ -30,6 +32,13 @@ go_test( env = { "SUBMITQUEUE_TEST_GIT": "$(location @git//:git)", }, + # The GitHub suite lands real pull requests in a test repository when + # these are set, and skips otherwise (see test/testutil/githubtestrepo). + env_inherit = [ + "SQ_GITHUB_TEST_REPO", + "SQ_GITHUB_TEST_REQUIRED", + "SQ_GITHUB_TOKEN", + ], tags = [ "e2e", "integration", @@ -58,6 +67,7 @@ go_test( "//submitqueue/orchestrator/core/batch:go_default_library", "//submitqueue/orchestrator/extension/storage/mysql:go_default_library", "//test/testutil:go_default_library", + "//test/testutil/githubtestrepo:go_default_library", "@com_github_stretchr_testify//assert:go_default_library", "@com_github_stretchr_testify//require:go_default_library", "@com_github_stretchr_testify//suite:go_default_library", diff --git a/test/e2e/submitqueue/github_suite_test.go b/test/e2e/submitqueue/github_suite_test.go new file mode 100644 index 000000000..a16ad06be --- /dev/null +++ b/test/e2e/submitqueue/github_suite_test.go @@ -0,0 +1,198 @@ +// 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. + +// End-to-end coverage of landing real pull requests on github.com. +// +// The git suite (git_suite_test.go) proves the merge machinery against a bare +// repository and deliberately leaves out the half that is specific to a change +// provider. This suite covers that half: the orchestrator reads change metadata +// from GitHub, and Runway lands each change through the GitHub merger, so a +// request reaching `landed` here means GitHub itself merged the pull requests. +// Builds stay fake — what is under test is the land path, not CI. +// +// It needs a test repository and a token with write access to it, and skips +// without them; see test/testutil/githubtestrepo. +package e2e_test + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + changepb "github.com/uber/submitqueue/api/base/change/protopb" + mergestrategypb "github.com/uber/submitqueue/api/base/mergestrategy/protopb" + gatewaypb "github.com/uber/submitqueue/api/submitqueue/gateway/protopb" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/test/testutil" + "github.com/uber/submitqueue/test/testutil/githubtestrepo" +) + +// githubQueue is the queue the generated provider configuration wires to +// GitHub; it is also declared in the gateway's queues.yaml. +const githubQueue = "e2e-github-queue" + +type GitHubLandSuite struct { + suite.Suite + ctx context.Context + log *testutil.TestLogger + githubRepo *githubtestrepo.TestRepo + stack *testutil.ComposeStack + gatewayClient gatewaypb.SubmitQueueGatewayClient +} + +func TestGitHubLandE2E(t *testing.T) { + suite.Run(t, new(GitHubLandSuite)) +} + +func (s *GitHubLandSuite) SetupSuite() { + t := s.T() + s.ctx = context.Background() + s.log = testutil.NewTestLogger(t) + // Skips the suite before any container starts when no test repository is configured. + s.githubRepo = githubtestrepo.New(t) + + t.Setenv("SQ_CONTAINER_USER", dockerContainerUser(t)) + t.Setenv("SQ_CONSUMER_GATE_DIR", t.TempDir()) + t.Setenv("SQ_PROVIDER_CONFIG_DIR", s.writeProviderConfig()) + t.Setenv("GITHUB_TOKEN", s.githubRepo.Token()) + + composeFile := testutil.Runfile("service/submitqueue/docker-compose.yml") + s.stack = testutil.NewComposeStack(t, s.log, s.ctx, composeFile, "e2e-submitqueue-github", + testutil.WithOverlay(testutil.Runfile("service/submitqueue/docker-compose.provider.yml")), + testutil.WithBuildContext(map[string]string{ + ".docker-bin/gateway": "service/submitqueue/gateway/server/gateway_linux", + ".docker-bin/orchestrator": "service/submitqueue/orchestrator/server/orchestrator_linux", + ".docker-bin/runway": "service/runway/server/runway_linux", + "service/submitqueue/gateway/server/Dockerfile": "service/submitqueue/gateway/server/Dockerfile", + "service/submitqueue/gateway/server/queues.yaml": "service/submitqueue/gateway/server/queues.yaml", + "service/submitqueue/orchestrator/server/Dockerfile": "service/submitqueue/orchestrator/server/Dockerfile", + "service/runway/server/Dockerfile": "service/runway/server/Dockerfile", + })) + require.NoError(t, s.stack.Up(), "failed to start compose stack") + + db, err := s.stack.ConnectMySQLService("mysql-app") + require.NoError(t, err) + t.Cleanup(func() { db.Close() }) + queueDB, err := s.stack.ConnectMySQLService("mysql-queue") + require.NoError(t, err) + t.Cleanup(func() { queueDB.Close() }) + testutil.ApplySubmitQueueStorageSchema(t, s.log, db) + testutil.ApplySchema(t, s.log, db, testutil.SchemaDir("platform/extension/counter/mysql/schema")) + testutil.ApplySchema(t, s.log, queueDB, testutil.SchemaDir("platform/extension/messagequeue/mysql/schema")) + + conn, err := s.stack.ConnectGRPC("gateway-service", 8080) + require.NoError(t, err) + s.gatewayClient = gatewaypb.NewSubmitQueueGatewayClient(conn) + s.log.Logf("github E2E suite ready (repository %s/%s)", s.githubRepo.Owner(), s.githubRepo.Repo()) +} + +func (s *GitHubLandSuite) TestLand_SinglePullRequest_IsMergedOnGitHub() { + p := s.githubRepo.OpenPull("e2e-single", s.githubRepo.Trunk()) + + s.requireStatus(s.land(p), entity.RequestStatusLanded) + + s.True(s.githubRepo.PullState(p.Number).Merged, "pull request #%d must be merged", p.Number) + s.True(s.githubRepo.FileOnTrunk(s.githubRepo.FilePath("e2e-single")), "its change must be on the trunk") +} + +func (s *GitHubLandSuite) TestLand_Stack_IsMergedOnGitHub() { + pulls := s.githubRepo.OpenStack("e2e-stack", 2) + + s.requireStatus(s.land(pulls...), entity.RequestStatusLanded) + + for i, p := range pulls { + s.True(s.githubRepo.PullState(p.Number).Merged, "pull request #%d must be merged", p.Number) + s.True(s.githubRepo.FileOnTrunk(s.githubRepo.FilePath(fmt.Sprintf("e2e-stack-%d", i+1)))) + } +} + +func (s *GitHubLandSuite) TestLand_PullRequestsThatAreNotAStack_AreRejected() { + pulls := []githubtestrepo.Pull{ + s.githubRepo.OpenPull("e2e-independent-1", s.githubRepo.Trunk()), + s.githubRepo.OpenPull("e2e-independent-2", s.githubRepo.Trunk()), + } + + s.requireStatus(s.land(pulls...), entity.RequestStatusError) + + for _, p := range pulls { + s.False(s.githubRepo.PullState(p.Number).Merged, "pull request #%d must not merge", p.Number) + } +} + +// land submits the pull requests as one change, bottom of the stack first, and +// returns the request's sqid. +func (s *GitHubLandSuite) land(pulls ...githubtestrepo.Pull) string { + resp, err := s.gatewayClient.Land(s.ctx, &gatewaypb.LandRequest{ + Queue: githubQueue, + Change: &changepb.Change{Uris: s.githubRepo.URIs(pulls...)}, + Strategy: mergestrategypb.Strategy_SQUASH_REBASE, + }) + s.Require().NoError(err, "Land failed") + s.Require().NotEmpty(resp.Sqid) + return resp.Sqid +} + +// requireStatus waits for the request to reach a terminal status and asserts +// which one. Bazel's test timeout is the only deadline. +func (s *GitHubLandSuite) requireStatus(sqid string, want entity.RequestStatus) { + var got entity.RequestStatus + pollUntil(persistPollInterval, func() bool { + resp, err := s.gatewayClient.GetRequestSummaryByID(s.ctx, &gatewaypb.GetRequestSummaryByIDRequest{Sqid: sqid, Queue: githubQueue}) + if err != nil || resp.Request == nil { + return false + } + got = entity.RequestStatus(resp.Request.Status) + s.log.Logf("request %s status=%q (awaiting terminal)", sqid, got) + return isTerminalStatus(got) + }) + s.Require().Equal(want, got, "request %s reached the wrong terminal status", sqid) +} + +// writeProviderConfig writes the orchestrator profiles and Runway merge targets +// for githubQueue, pointed at the configured test repository, and returns their +// directory. Generated rather than committed because the test repository is +// itself configuration. +func (s *GitHubLandSuite) writeProviderConfig() string { + t := s.T() + dir := t.TempDir() + profiles := fmt.Sprintf(`defaults: + changeProvider: {type: fake} + buildRunner: {type: fake} + analyzer: {type: all} +queues: + - name: %s + changeProvider: {type: github} + analyzer: {type: none} +`, githubQueue) + merge := fmt.Sprintf(`defaults: + merger: {type: noop} +queues: + - name: %s + merger: + type: github + owner: %s + repo: %s + target: %s + defaultStrategy: SQUASH_REBASE + tokenEnv: GITHUB_TOKEN + pollInterval: 1s +`, githubQueue, s.githubRepo.Owner(), s.githubRepo.Repo(), s.githubRepo.Trunk()) + require.NoError(t, os.WriteFile(filepath.Join(dir, "profiles.yaml"), []byte(profiles), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "merge.yaml"), []byte(merge), 0o644)) + return dir +} diff --git a/test/integration/runway/extension/merger/github/BUILD.bazel b/test/integration/runway/extension/merger/github/BUILD.bazel new file mode 100644 index 000000000..38f57023d --- /dev/null +++ b/test/integration/runway/extension/merger/github/BUILD.bazel @@ -0,0 +1,32 @@ +load("@rules_go//go:def.bzl", "go_test") + +go_test( + name = "go_default_test", + timeout = "long", + srcs = ["github_test.go"], + # The test repository and its credential reach the test only when set; without it the + # test skips (see test/testutil/githubtestrepo). + env_inherit = [ + "SQ_GITHUB_TEST_REPO", + "SQ_GITHUB_TEST_REQUIRED", + "SQ_GITHUB_TOKEN", + ], + tags = [ + "external", + "integration", + "requires-network", + ], + deps = [ + "//api/base/change/protopb:go_default_library", + "//api/base/mergestrategy/protopb:go_default_library", + "//api/runway/messagequeue:go_default_library", + "//api/runway/messagequeue/protopb:go_default_library", + "//runway/extension/merger:go_default_library", + "//runway/extension/merger/github:go_default_library", + "//test/testutil/githubtestrepo:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + "@com_github_uber_go_tally//:go_default_library", + "@org_uber_go_zap//zaptest:go_default_library", + ], +) diff --git a/test/integration/runway/extension/merger/github/github_test.go b/test/integration/runway/extension/merger/github/github_test.go new file mode 100644 index 000000000..39c17eef9 --- /dev/null +++ b/test/integration/runway/extension/merger/github/github_test.go @@ -0,0 +1,162 @@ +// 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 github exercises the GitHub merger extension on its own against a +// real repository on github.com. It opens throwaway branches, pull requests and +// stacks in a test repository, lands them through the merger, and checks +// what GitHub recorded — the one place the merger's assumptions about the +// stacks and async merge APIs meet the real service rather than a fake. +// +// It skips unless a test repository is configured; see test/testutil/githubtestrepo. +package github + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" + "go.uber.org/zap/zaptest" + + changepb "github.com/uber/submitqueue/api/base/change/protopb" + mergestrategypb "github.com/uber/submitqueue/api/base/mergestrategy/protopb" + runwaymq "github.com/uber/submitqueue/api/runway/messagequeue" + runwaypb "github.com/uber/submitqueue/api/runway/messagequeue/protopb" + "github.com/uber/submitqueue/runway/extension/merger" + githubmerger "github.com/uber/submitqueue/runway/extension/merger/github" + "github.com/uber/submitqueue/test/testutil/githubtestrepo" +) + +func newMerger(t *testing.T, repo *githubtestrepo.TestRepo, strategy mergestrategypb.Strategy) merger.Merger { + t.Helper() + m, err := githubmerger.New(githubmerger.Params{ + HTTPClient: repo.HTTPClient(), + Host: githubtestrepo.Host, + Owner: repo.Owner(), + Repo: repo.Repo(), + Target: repo.Trunk(), + DefaultStrategy: strategy, + PollInterval: time.Second, + MaxPollDuration: 3 * time.Minute, + Logger: zaptest.NewLogger(t).Sugar(), + MetricsScope: tally.NoopScope, + }) + require.NoError(t, err) + return m +} + +func mergeRequest(repo *githubtestrepo.TestRepo, strategy mergestrategypb.Strategy, pulls ...githubtestrepo.Pull) *runwaymq.MergeRequest { + return &runwaymq.MergeRequest{ + Id: "sq-it-" + repo.RunID(), + QueueName: "sq-it", + Steps: []*runwaymq.MergeStep{{ + StepId: "step-1", + Change: &changepb.Change{Uris: repo.URIs(pulls...)}, + Strategy: strategy, + }}, + } +} + +func outputIDs(result *runwaymq.MergeResult) []string { + var ids []string + for _, step := range result.GetSteps() { + for _, o := range step.GetOutputs() { + ids = append(ids, o.GetId()) + } + } + return ids +} + +func TestGitHubMergerLive(t *testing.T) { + ctx := context.Background() + + t.Run("stack lands in one merge and redelivery converges", func(t *testing.T) { + repo := githubtestrepo.New(t) + m := newMerger(t, repo, mergestrategypb.Strategy_SQUASH_REBASE) + pulls := repo.OpenStack("stack", 2) + req := mergeRequest(repo, mergestrategypb.Strategy_DEFAULT, pulls...) + + check, err := m.CheckMergeability(ctx, req) + require.NoError(t, err) + assert.Equal(t, runwaypb.Outcome_SUCCEEDED, check.GetOutcome()) + + result, err := m.Merge(ctx, req) + require.NoError(t, err) + assert.Equal(t, runwaypb.Outcome_SUCCEEDED, result.GetOutcome()) + + var want []string + for _, p := range pulls { + require.True(t, repo.PullState(p.Number).Merged, "pull request #%d", p.Number) + want = append(want, repo.MergeCommit(p.Number)) + } + assert.Equal(t, want, outputIDs(result)) + + redelivered, err := m.Merge(ctx, req) + require.NoError(t, err) + assert.Equal(t, want, outputIDs(redelivered), "a redelivery reports the same merge commits") + }) + + t.Run("bottom of a stack lands and leaves the rest open", func(t *testing.T) { + repo := githubtestrepo.New(t) + m := newMerger(t, repo, mergestrategypb.Strategy_REBASE) + pulls := repo.OpenStack("partial", 3) + req := mergeRequest(repo, mergestrategypb.Strategy_REBASE, pulls[0], pulls[1]) + + _, err := m.CheckMergeability(ctx, req) + require.NoError(t, err) + result, err := m.Merge(ctx, req) + require.NoError(t, err) + assert.Len(t, outputIDs(result), 2) + + assert.True(t, repo.PullState(pulls[0].Number).Merged) + assert.True(t, repo.PullState(pulls[1].Number).Merged) + top := repo.PullState(pulls[2].Number) + assert.False(t, top.Merged) + assert.Equal(t, "open", top.State) + }) + + t.Run("pull requests that are not a stack are rejected", func(t *testing.T) { + repo := githubtestrepo.New(t) + m := newMerger(t, repo, mergestrategypb.Strategy_SQUASH_REBASE) + independent := []githubtestrepo.Pull{repo.OpenPull("independent-1", repo.Trunk()), repo.OpenPull("independent-2", repo.Trunk())} + chained := repo.OpenChain("chain", 2) + + for name, pulls := range map[string][]githubtestrepo.Pull{"independent on trunk": independent, "chained without a stack": chained} { + req := mergeRequest(repo, mergestrategypb.Strategy_DEFAULT, pulls...) + _, err := m.CheckMergeability(ctx, req) + require.ErrorIs(t, err, merger.ErrInvalidRequest, name) + _, err = m.Merge(ctx, req) + require.ErrorIs(t, err, merger.ErrInvalidRequest, name) + for _, p := range pulls { + assert.False(t, repo.PullState(p.Number).Merged, "%s: #%d must not merge", name, p.Number) + } + } + }) + + t.Run("pull request whose head moved is rejected", func(t *testing.T) { + repo := githubtestrepo.New(t) + m := newMerger(t, repo, mergestrategypb.Strategy_SQUASH_REBASE) + p := repo.OpenPull("stale", repo.Trunk()) + req := mergeRequest(repo, mergestrategypb.Strategy_DEFAULT, p) + repo.AwaitPullHead(p.Number, repo.CommitFile(p.Branch, repo.FilePath("stale-followup"), "followup")) + + _, err := m.CheckMergeability(ctx, req) + require.ErrorIs(t, err, merger.ErrInvalidRequest) + _, err = m.Merge(ctx, req) + require.ErrorIs(t, err, merger.ErrInvalidRequest) + assert.False(t, repo.PullState(p.Number).Merged) + }) +} diff --git a/test/integration/submitqueue/gateway/suite_test.go b/test/integration/submitqueue/gateway/suite_test.go index 9baef2281..95ad64994 100644 --- a/test/integration/submitqueue/gateway/suite_test.go +++ b/test/integration/submitqueue/gateway/suite_test.go @@ -152,6 +152,7 @@ func (s *GatewayIntegrationSuite) TestListQueuesAPI() { "e2e-chain-queue", "e2e-conflict-error-queue", "e2e-git-queue", + "e2e-github-queue", "e2e-redelivery-queue", "e2e-respeculate-queue", "e2e-strand-queue", diff --git a/test/testutil/githubtestrepo/BUILD.bazel b/test/testutil/githubtestrepo/BUILD.bazel new file mode 100644 index 000000000..4b5c8303d --- /dev/null +++ b/test/testutil/githubtestrepo/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["githubtestrepo.go"], + importpath = "github.com/uber/submitqueue/test/testutil/githubtestrepo", + visibility = ["//visibility:public"], + deps = [ + "//platform/http:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + "@org_golang_x_oauth2//:go_default_library", + ], +) diff --git a/test/testutil/githubtestrepo/githubtestrepo.go b/test/testutil/githubtestrepo/githubtestrepo.go new file mode 100644 index 000000000..204e4e694 --- /dev/null +++ b/test/testutil/githubtestrepo/githubtestrepo.go @@ -0,0 +1,360 @@ +// 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 githubtestrepo gives tests real pull requests on github.com to land. +// +// The GitHub merger and the GitHub change provider talk to GitHub's REST API, +// and only real pull requests show what that API actually does — an in-process +// fake encodes only our reading of the docs. The suites that verify them (the +// merger extension test and TestGitHubLandE2E) share this package to open +// branches and pull requests, link them into stacks, push a new head, read back +// whether GitHub merged them and with which commit, and remove what they opened. +// +// New skips the calling test unless SQ_GITHUB_TOKEN and SQ_GITHUB_TEST_REPO +// ("owner/repo") are set, so runs without the credential never touch GitHub. +// Where the credential is expected — SQ_GITHUB_TEST_REQUIRED=true, which CI sets +// on every run that can read its secrets — a missing one fails the test instead, +// so a deleted secret cannot quietly turn these suites into skips. A token GitHub +// rejects (expired or revoked) always fails. Fixtures are named under sq-it// and closed and deleted when the test +// ends; what a test merges stays on the repository's default branch. +package githubtestrepo + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + phttp "github.com/uber/submitqueue/platform/http" +) + +const ( + // TokenEnv names the variable holding a token with write access to the + // test repository. + TokenEnv = "SQ_GITHUB_TOKEN" + // RepoEnv names the variable holding the test repository, "owner/repo". + RepoEnv = "SQ_GITHUB_TEST_REPO" + // RequiredEnv names the variable that, when "true", makes a missing + // TokenEnv or RepoEnv fail the test rather than skip it. + RequiredEnv = "SQ_GITHUB_TEST_REQUIRED" + + // Host is the GitHub instance the test repository's change URIs name. + Host = "github.com" + // APIBaseURL is the REST API root for Host. + APIBaseURL = "https://api.github.com" + + apiVersion = "2026-03-10" +) + +// TestRepo creates fixtures in the test repository and removes them when the +// test ends. +type TestRepo struct { + t testing.TB + httpClient *http.Client + token string + owner string + repo string + trunk string + runID string + branches []string + pulls []int +} + +// Pull is a fixture pull request. +type Pull struct { + // Number is the pull request number. + Number int + // Branch is the pull request's head branch. + Branch string + // Head is the head commit the pull request was opened at. + Head string +} + +// New returns a TestRepo for the configured repository. When the credential or +// repository is not configured it skips the test, or fails it if RequiredEnv is +// "true". +func New(t testing.TB) *TestRepo { + t.Helper() + token, ownerRepo := os.Getenv(TokenEnv), os.Getenv(RepoEnv) + if token == "" || ownerRepo == "" { + if os.Getenv(RequiredEnv) == "true" { + require.FailNowf(t, "GitHub test repository not configured", + "%s=true but %s or %s is empty; check the CI secret", RequiredEnv, TokenEnv, RepoEnv) + } + t.Skipf("set %s and %s=owner/repo to run against github.com", TokenEnv, RepoEnv) + } + owner, repo, ok := strings.Cut(ownerRepo, "/") + require.True(t, ok, "%s must be owner/repo", RepoEnv) + + httpClient, err := phttp.NewClient(APIBaseURL) + require.NoError(t, err) + httpClient.Timeout = 30 * time.Second + httpClient.Transport = &oauth2.Transport{ + Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}), + Base: httpClient.Transport, + } + + r := &TestRepo{ + t: t, + httpClient: httpClient, + token: token, + owner: owner, + repo: repo, + runID: fmt.Sprintf("%d", time.Now().UnixNano()), + } + var repoInfo struct { + DefaultBranch string `json:"default_branch"` + } + r.call(http.MethodGet, r.path(""), nil, &repoInfo) + r.trunk = repoInfo.DefaultBranch + t.Cleanup(r.cleanup) + return r +} + +// HTTPClient is an authenticated client rooted at APIBaseURL. +func (r *TestRepo) HTTPClient() *http.Client { return r.httpClient } + +// Token is the credential the test repository is reached with. +func (r *TestRepo) Token() string { return r.token } + +// Owner is the test repository's owner. +func (r *TestRepo) Owner() string { return r.owner } + +// Repo is the test repository's name. +func (r *TestRepo) Repo() string { return r.repo } + +// Trunk is the test repository's default branch. +func (r *TestRepo) Trunk() string { return r.trunk } + +// RunID uniquely names this test run's fixtures. +func (r *TestRepo) RunID() string { return r.runID } + +// URI is the change URI naming p at the head it was opened with. +func (r *TestRepo) URI(p Pull) string { + return fmt.Sprintf("github://%s/%s/%s/pull/%d/%s", Host, r.owner, r.repo, p.Number, p.Head) +} + +// URIs is URI for each pull request, in order. +func (r *TestRepo) URIs(pulls ...Pull) []string { + uris := make([]string, 0, len(pulls)) + for _, p := range pulls { + uris = append(uris, r.URI(p)) + } + return uris +} + +// OpenStack opens n pull requests, each adding its own file, the first based on +// the trunk and each later one on the branch below it, and links them into a +// GitHub stack. +func (r *TestRepo) OpenStack(name string, n int) []Pull { + pulls := r.OpenChain(name, n) + numbers := make([]int, 0, n) + for _, p := range pulls { + numbers = append(numbers, p.Number) + } + r.call(http.MethodPost, r.path("/stacks"), map[string]any{"pull_requests": numbers}, nil) + return pulls +} + +// OpenChain opens n pull requests stacked by base branch without creating a +// GitHub stack. +func (r *TestRepo) OpenChain(name string, n int) []Pull { + base := r.trunk + pulls := make([]Pull, 0, n) + for i := 1; i <= n; i++ { + p := r.OpenPull(fmt.Sprintf("%s-%d", name, i), base) + pulls = append(pulls, p) + base = p.Branch + } + return pulls +} + +// OpenPull opens one pull request against base that adds a file unique to it. +func (r *TestRepo) OpenPull(name, base string) Pull { + r.t.Helper() + branch := fmt.Sprintf("sq-it/%s/%s", r.runID, name) + var baseRef struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` + } + r.call(http.MethodGet, r.path("/git/ref/heads/"+base), nil, &baseRef) + r.call(http.MethodPost, r.path("/git/refs"), map[string]string{"ref": "refs/heads/" + branch, "sha": baseRef.Object.SHA}, nil) + r.branches = append(r.branches, branch) + + head := r.CommitFile(branch, r.FilePath(name), name) + var pr struct { + Number int `json:"number"` + } + r.call(http.MethodPost, r.path("/pulls"), map[string]string{ + "title": fmt.Sprintf("sq integration %s (%s)", name, r.runID), + "head": branch, + "base": base, + "body": "Opened by a SubmitQueue integration test; closed or merged automatically.", + }, &pr) + r.pulls = append(r.pulls, pr.Number) + return Pull{Number: pr.Number, Branch: branch, Head: head} +} + +// FilePath is the path of the file a fixture named name adds. +func (r *TestRepo) FilePath(name string) string { + return fmt.Sprintf("sq-it/%s/%s.txt", r.runID, name) +} + +// CommitFile writes a file on branch and returns the new head commit. +func (r *TestRepo) CommitFile(branch, path, content string) string { + r.t.Helper() + body := map[string]string{ + "message": "sq integration " + path, + "content": base64.StdEncoding.EncodeToString([]byte(content + "\n")), + "branch": branch, + } + var existing struct { + SHA string `json:"sha"` + } + if code, _ := r.send(http.MethodGet, r.path("/contents/"+path+"?ref="+branch), nil, &existing); code == http.StatusOK { + body["sha"] = existing.SHA + } + var out struct { + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` + } + r.call(http.MethodPut, r.path("/contents/"+path), body, &out) + return out.Commit.SHA +} + +// AwaitPullHead waits until GitHub reports head as the pull request's head. A +// push reaches the pull request asynchronously, so a staleness check run before +// that would compare against the old head. Bounded by the test runner's +// timeout. +func (r *TestRepo) AwaitPullHead(number int, head string) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + var pr struct { + Head struct { + SHA string `json:"sha"` + } `json:"head"` + } + r.call(http.MethodGet, r.path(fmt.Sprintf("/pulls/%d", number)), nil, &pr) + if pr.Head.SHA == head { + return + } + <-ticker.C + } +} + +// PullState is what GitHub reports about a fixture pull request. +type PullState struct { + // State is "open" or "closed". + State string `json:"state"` + // Merged reports whether the pull request was merged. + Merged bool `json:"merged"` +} + +// PullState reads a pull request's current state. +func (r *TestRepo) PullState(number int) PullState { + var st PullState + r.call(http.MethodGet, r.path(fmt.Sprintf("/pulls/%d", number)), nil, &st) + return st +} + +// MergeCommit is the commit GitHub's merged event records for a pull request, +// or "" when it has none. +func (r *TestRepo) MergeCommit(number int) string { + var events []struct { + Event string `json:"event"` + CommitID string `json:"commit_id"` + } + r.call(http.MethodGet, r.path(fmt.Sprintf("/issues/%d/events?per_page=100", number)), nil, &events) + for _, e := range events { + if e.Event == "merged" { + return e.CommitID + } + } + return "" +} + +// FileOnTrunk reports whether path exists on the trunk. +func (r *TestRepo) FileOnTrunk(path string) bool { + code, _ := r.send(http.MethodGet, r.path("/contents/"+path+"?ref="+r.trunk), nil, nil) + return code == http.StatusOK +} + +// cleanup closes every fixture pull request still open and deletes every +// fixture branch. Failures are logged, not fatal: a leftover branch in the +// test repository is harmless and must not mask the test's own result. +func (r *TestRepo) cleanup() { + // Every fixture pull request is closed without first reading its state: a + // failed read must not stop cleanup, and closing a merged or already closed + // one is a harmless rejection. + for _, n := range r.pulls { + r.cleanupRequest(http.MethodPatch, r.path(fmt.Sprintf("/pulls/%d", n)), map[string]string{"state": "closed"}) + } + for _, b := range r.branches { + r.cleanupRequest(http.MethodDelete, r.path("/git/refs/heads/"+b), nil) + } +} + +// cleanupRequest sends one cleanup request and logs, rather than fails on, a +// rejection, so one bad fixture cannot strand the rest. A 422 is GitHub saying +// the pull request is already merged or closed, or the branch already gone. +func (r *TestRepo) cleanupRequest(method, path string, body any) { + if code, resp := r.send(method, path, body, nil); (code < 200 || code >= 300) && code != http.StatusUnprocessableEntity { + r.t.Logf("cleanup %s %s: status %d: %s", method, path, code, resp) + } +} + +func (r *TestRepo) path(suffix string) string { + return "/repos/" + r.owner + "/" + r.repo + suffix +} + +// call sends a request and fails the test on a non-2xx answer. +func (r *TestRepo) call(method, path string, body, out any) { + r.t.Helper() + code, resp := r.send(method, path, body, out) + require.True(r.t, code >= 200 && code < 300, "%s %s: status %d: %s", method, path, code, resp) +} + +func (r *TestRepo) send(method, path string, body, out any) (int, string) { + r.t.Helper() + var payload []byte + if body != nil { + var err error + payload, err = json.Marshal(body) + require.NoError(r.t, err) + } + code, resp, err := phttp.SendRequest(context.Background(), r.httpClient, method, path, payload, func(r *http.Request) { + r.Header.Set("Accept", "application/vnd.github+json") + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-GitHub-Api-Version", apiVersion) + }) + if err != nil { + r.t.Logf("%s %s: %v", method, path, err) + return 0, "" + } + if out != nil && code >= 200 && code < 300 && len(resp) > 0 { + require.NoError(r.t, json.Unmarshal(resp, out), "%s %s", method, path) + } + return code, string(resp) +}