Ctrl K
ComunidadGuías…

action/command/vcs_user_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package command

import (
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/require"
	"google.golang.org/protobuf/proto"

	"github.com/bytebase/bytebase/action/bitbucket"
	"github.com/bytebase/bytebase/action/world"
	v1pb "github.com/bytebase/bytebase/backend/generated-go/v1"
)

func TestGetVCSUserFromGitHubPullRequest(t *testing.T) {
	eventPath := filepath.Join(t.TempDir(), "event.json")
	require.NoError(t, os.WriteFile(eventPath, []byte(`{"pull_request":{"user":{"id":1001,"login":"alice","name":"Alice","type":"User"}}}`), 0600))
	t.Setenv("GITHUB_EVENT_NAME", "pull_request")
	t.Setenv("GITHUB_EVENT_PATH", eventPath)

	user := getVCSUser(world.GitHub)
	require.NotNil(t, user)
	require.Equal(t, v1pb.VCSType_GITHUB, user.VcsType)
	require.Equal(t, "1001", user.UserId)
	require.Equal(t, "alice", user.UserName)
	require.Equal(t, "Alice", user.DisplayName)
}

func TestGetVCSUserSkipsGitHubBot(t *testing.T) {
	eventPath := filepath.Join(t.TempDir(), "event.json")
	require.NoError(t, os.WriteFile(eventPath, []byte(`{"pull_request":{"user":{"id":41898282,"login":"github-actions[bot]","type":"Bot"}}}`), 0600))
	t.Setenv("GITHUB_EVENT_NAME", "pull_request")
	t.Setenv("GITHUB_EVENT_PATH", eventPath)

	require.Nil(t, getVCSUser(world.GitHub))
}

func TestGetVCSUserFromGitHubPullRequestTarget(t *testing.T) {
	eventPath := filepath.Join(t.TempDir(), "event.json")
	require.NoError(t, os.WriteFile(eventPath, []byte(`{"pull_request":{"user":{"id":1001,"login":"alice","name":"Alice","type":"User"}}}`), 0600))
	t.Setenv("GITHUB_EVENT_NAME", "pull_request_target")
	t.Setenv("GITHUB_EVENT_PATH", eventPath)

	user := getVCSUser(world.GitHub)
	require.NotNil(t, user)
	require.Equal(t, v1pb.VCSType_GITHUB, user.VcsType)
	require.Equal(t, "1001", user.UserId)
}

func TestGetVCSUserSkipsGitLabWhenAuthorUnavailable(t *testing.T) {
	t.Setenv("CI_PIPELINE_SOURCE", "merge_request_event")
	t.Setenv("GITLAB_USER_ID", "2002")
	t.Setenv("GITLAB_USER_LOGIN", "bob")
	t.Setenv("GITLAB_USER_NAME", "Bob")

	require.Nil(t, getVCSUser(world.GitLab))
}

func TestGetVCSUserFromGitLabMergeRequestAuthor(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "/api/v4/projects/987/merge_requests/42", r.URL.Path)
		require.Equal(t, "job-token", r.Header.Get("JOB-TOKEN"))
		w.Header().Set("Content-Type", "application/json")
		_, err := w.Write([]byte(`{"author":{"id":3003,"username":"alice","name":"Alice"}}`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("CI_MERGE_REQUEST_PROJECT_ID", "987")
	t.Setenv("CI_MERGE_REQUEST_IID", "42")
	t.Setenv("CI_API_V4_URL", server.URL+"/api/v4")
	t.Setenv("CI_JOB_TOKEN", "job-token")
	t.Setenv("GITLAB_USER_ID", "2002")
	t.Setenv("GITLAB_USER_LOGIN", "triggerer")
	t.Setenv("GITLAB_USER_NAME", "Pipeline Triggerer")

	user := getVCSUser(world.GitLab)
	require.NotNil(t, user)
	require.Equal(t, v1pb.VCSType_GITLAB, user.VcsType)
	require.Equal(t, "3003", user.UserId)
	require.Equal(t, "alice", user.UserName)
	require.Equal(t, "Alice", user.DisplayName)
}

func TestGetVCSUserSkipsGitLabBotAuthor(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, err := w.Write([]byte(`{"author":{"id":4004,"username":"project_987_bot_a1b2c3","name":"Project bot"}}`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("CI_MERGE_REQUEST_PROJECT_ID", "987")
	t.Setenv("CI_MERGE_REQUEST_IID", "42")
	t.Setenv("CI_API_V4_URL", server.URL+"/api/v4")
	t.Setenv("CI_JOB_TOKEN", "job-token")

	require.Nil(t, getVCSUser(world.GitLab))
}

func TestGetVCSUserSkipsBitbucketWhenAuthorUnavailable(t *testing.T) {
	t.Setenv("BITBUCKET_PR_ID", "10")
	t.Setenv("BITBUCKET_STEP_TRIGGERER_UUID", "{3003}")
	t.Setenv("BITBUCKET_STEP_TRIGGERER_USERNAME", "carol")

	require.Nil(t, getVCSUser(world.Bitbucket))
}

func TestGetVCSUserFromBitbucketPullRequestAuthor(t *testing.T) {
	var gotPath string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		require.Equal(t, http.MethodGet, r.Method)
		require.Equal(t, "application/json", r.Header.Get("Accept"))
		w.Header().Set("Content-Type", "application/json")
		_, err := w.Write([]byte(`{"author":{"account_id":"557058:author-account","uuid":"{author-uuid}","nickname":"alice","display_name":"Alice","type":"user"}}`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("BITBUCKET_PR_ID", "10")
	t.Setenv("BITBUCKET_REPO_FULL_NAME", "bytebase/example")
	t.Setenv("BITBUCKET_STEP_TRIGGERER_UUID", "{triggerer-uuid}")
	t.Setenv("BITBUCKET_STEP_TRIGGERER_USERNAME", "triggerer")
	t.Setenv("BYTEBASE_BITBUCKET_API_BASE_URL", server.URL)

	user := getVCSUser(world.Bitbucket)
	require.NotNil(t, user)
	require.Equal(t, "/repositories/bytebase/example/pullrequests/10", gotPath)
	require.Equal(t, v1pb.VCSType_BITBUCKET, user.VcsType)
	require.Equal(t, "557058:author-account", user.UserId)
	require.Equal(t, "alice", user.UserName)
	require.Equal(t, "Alice", user.DisplayName)
}

func TestGetVCSUserFromAzureDevOpsPullRequestCreator(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, http.MethodGet, r.Method)
		require.Equal(t, "/collection/_apis/git/pullrequests/42", r.URL.Path)
		require.Equal(t, "7.1", r.URL.Query().Get("api-version"))
		require.Equal(t, "Bearer access-token", r.Header.Get("Authorization"))
		require.Equal(t, "application/json", r.Header.Get("Accept"))
		w.Header().Set("Content-Type", "application/json")
		_, err := w.Write([]byte(`{"createdBy":{"id":"author-id","uniqueName":"alice@example.com","displayName":"Alice"}}`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("SYSTEM_PULLREQUEST_PULLREQUESTID", "42")
	t.Setenv("SYSTEM_COLLECTIONURI", server.URL+"/collection/")
	t.Setenv("SYSTEM_ACCESSTOKEN", "access-token")

	user := getVCSUser(world.AzureDevOps)
	require.NotNil(t, user)
	require.Equal(t, v1pb.VCSType_AZURE_DEVOPS, user.VcsType)
	require.Equal(t, "author-id", user.UserId)
	require.Equal(t, "alice@example.com", user.UserName)
	require.Equal(t, "Alice", user.DisplayName)
}

func TestGetVCSUserFromAzureDevOpsPullRequestCreatorWithTeamFoundationCollectionURI(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "/collection/_apis/git/pullrequests/42", r.URL.Path)
		_, err := w.Write([]byte(`{"createdBy":{"id":"author-id","uniqueName":"alice@example.com","displayName":"Alice"}}`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("SYSTEM_PULLREQUEST_PULLREQUESTID", "42")
	t.Setenv("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI", server.URL+"/collection/")
	t.Setenv("SYSTEM_ACCESSTOKEN", "access-token")

	require.NotNil(t, getVCSUser(world.AzureDevOps))
}

func TestGetVCSUserSkipsAzureDevOpsWhenPullRequestMetadataUnavailable(t *testing.T) {
	t.Setenv("SYSTEM_COLLECTIONURI", "https://dev.azure.com/bytebase/")
	t.Setenv("SYSTEM_ACCESSTOKEN", "access-token")
	t.Setenv("BUILD_REQUESTEDFORID", "requester-id")
	t.Setenv("BUILD_REQUESTEDFOR", "Requester")

	require.Nil(t, getVCSUser(world.AzureDevOps))
}

func TestGetVCSUserSkipsAzureDevOpsWhenAccessTokenUnavailable(t *testing.T) {
	t.Setenv("SYSTEM_PULLREQUEST_PULLREQUESTID", "42")
	t.Setenv("SYSTEM_COLLECTIONURI", "https://dev.azure.com/bytebase/")

	require.Nil(t, getVCSUser(world.AzureDevOps))
}

func TestGetVCSUserSkipsAzureDevOpsServicePrincipalCreator(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, err := w.Write([]byte(`{"createdBy":{"id":"bot-id","uniqueName":"build-service","displayName":"Build Service","descriptor":"aadsp.service-principal"}}`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("SYSTEM_PULLREQUEST_PULLREQUESTID", "42")
	t.Setenv("SYSTEM_COLLECTIONURI", server.URL)
	t.Setenv("SYSTEM_ACCESSTOKEN", "access-token")

	require.Nil(t, getVCSUser(world.AzureDevOps))
}

func TestGetVCSUserSkipsAzureDevOpsBuildServiceCreator(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, err := w.Write([]byte(`{"createdBy":{"id":"bot-id","uniqueName":"build-service","displayName":"Build Service","descriptor":"svc.build-service"}}`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("SYSTEM_PULLREQUEST_PULLREQUESTID", "42")
	t.Setenv("SYSTEM_COLLECTIONURI", server.URL)
	t.Setenv("SYSTEM_ACCESSTOKEN", "access-token")

	require.Nil(t, getVCSUser(world.AzureDevOps))
}

func TestGetVCSUserSkipsAzureDevOpsBotCreator(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, err := w.Write([]byte(`{"createdBy":{"id":"bot-id","uniqueName":"release[bot]","displayName":"Release Bot"}}`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("SYSTEM_PULLREQUEST_PULLREQUESTID", "42")
	t.Setenv("SYSTEM_COLLECTIONURI", server.URL)
	t.Setenv("SYSTEM_ACCESSTOKEN", "access-token")

	require.Nil(t, getVCSUser(world.AzureDevOps))
}

func TestGetVCSUserSkipsAzureDevOpsCreatorWithoutID(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, err := w.Write([]byte(`{"createdBy":{"uniqueName":"alice@example.com","displayName":"Alice"}}`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("SYSTEM_PULLREQUEST_PULLREQUESTID", "42")
	t.Setenv("SYSTEM_COLLECTIONURI", server.URL)
	t.Setenv("SYSTEM_ACCESSTOKEN", "access-token")

	require.Nil(t, getVCSUser(world.AzureDevOps))
}

func TestGetVCSUserSkipsAzureDevOpsInvalidPullRequestResponse(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		_, err := w.Write([]byte(`invalid`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("SYSTEM_PULLREQUEST_PULLREQUESTID", "42")
	t.Setenv("SYSTEM_COLLECTIONURI", server.URL)
	t.Setenv("SYSTEM_ACCESSTOKEN", "access-token")

	require.Nil(t, getVCSUser(world.AzureDevOps))
}

func TestGetVCSUserSkipsAzureDevOpsNonOKPullRequestResponse(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusUnauthorized)
	}))
	t.Cleanup(server.Close)

	t.Setenv("SYSTEM_PULLREQUEST_PULLREQUESTID", "42")
	t.Setenv("SYSTEM_COLLECTIONURI", server.URL)
	t.Setenv("SYSTEM_ACCESSTOKEN", "access-token")

	require.Nil(t, getVCSUser(world.AzureDevOps))
}

func TestBitbucketDefaultAPIUsesPipelinesProxy(t *testing.T) {
	requestURL, err := buildBitbucketPullRequestURL(getBitbucketAPIBaseURL(), "bytebase", "example", "10")
	require.NoError(t, err)
	require.Equal(t, "http://api.bitbucket.org/2.0/repositories/bytebase/example/pullrequests/10", requestURL)

	client := bitbucket.NewHTTPClient(getBitbucketAPIBaseURL())
	transport, ok := client.Transport.(*http.Transport)
	require.True(t, ok)
	proxyURL, err := transport.Proxy(&http.Request{URL: mustParseURL(t, requestURL)})
	require.NoError(t, err)
	require.Equal(t, "http://localhost:29418", proxyURL.String())
}

func TestGetVCSUserSkipsBitbucketBotAuthor(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, err := w.Write([]byte(`{"author":{"account_id":"557058:bot-account","nickname":"release[bot]","type":"bot"}}`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("BITBUCKET_PR_ID", "10")
	t.Setenv("BITBUCKET_REPO_FULL_NAME", "bytebase/example")
	t.Setenv("BYTEBASE_BITBUCKET_API_BASE_URL", server.URL)

	require.Nil(t, getVCSUser(world.Bitbucket))
}

func TestGetVCSUserSkipsBitbucketAppUserAuthor(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, err := w.Write([]byte(`{"author":{"account_id":"557058:app-account","nickname":"release-app","type":"app_user"}}`))
		require.NoError(t, err)
	}))
	t.Cleanup(server.Close)

	t.Setenv("BITBUCKET_PR_ID", "10")
	t.Setenv("BITBUCKET_REPO_FULL_NAME", "bytebase/example")
	t.Setenv("BYTEBASE_BITBUCKET_API_BASE_URL", server.URL)

	require.Nil(t, getVCSUser(world.Bitbucket))
}

func TestGetVCSUserSkipsUnsupportedPlatform(t *testing.T) {
	require.Nil(t, getVCSUser(world.LocalPlatform))
}

func mustParseURL(t *testing.T, rawURL string) *url.URL {
	t.Helper()
	parsedURL, err := url.Parse(rawURL)
	require.NoError(t, err)
	return parsedURL
}

func TestGetVCSSource(t *testing.T) {
	githubPush := map[string]string{
		"GITHUB_SERVER_URL": "https://github.com",
		"GITHUB_REPOSITORY": "example/app",
		"GITHUB_SHA":        "0a1b2c3d",
		"GITHUB_EVENT_NAME": "push",
		"GITHUB_REF_NAME":   "main",
		"GITHUB_REF_TYPE":   "branch",
		githubEventPayload:  "",
	}
	with := func(base map[string]string, extra map[string]string) map[string]string {
		out := map[string]string{}
		for k, v := range base {
			out[k] = v
		}
		for k, v := range extra {
			out[k] = v
		}
		return out
	}
	tests := []struct {
		name     string
		platform world.JobPlatform
		env      map[string]string
		flags    func(w *world.World)
		want     *v1pb.Release_VCSSource
	}{
		{
			name:     "GitHub pull request run uses the source branch and the PR",
			platform: world.GitHub,
			env: with(githubPush, map[string]string{
				"GITHUB_EVENT_NAME": "pull_request",
				"GITHUB_REF_NAME":   "42/merge",
				"GITHUB_HEAD_REF":   "feature/orders",
				githubEventPayload:  `{"pull_request":{"number":42,"title":"Add the orders table","html_url":"https://github.com/example/app/pull/42","user":{"login":"alexdoe"}}}`,
			}),
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITHUB, Url: "https://github.com/example/app/commit/0a1b2c3d",
				CommitSha: "0a1b2c3d", Branch: "feature/orders", AuthorLogin: "alexdoe",
				PullRequest: &v1pb.Release_VCSSource_PullRequest{Url: "https://github.com/example/app/pull/42", Number: 42, Title: "Add the orders table"},
			},
		},
		{
			name:     "GitHub squash-merge push takes the PR number from the title once",
			platform: world.GitHub,
			env: with(githubPush, map[string]string{
				githubEventPayload: `{"head_commit":{"message":"Add the orders table (#42)\n\n* first commit","author":{"name":"Alex Doe","username":"alexdoe"}}}`,
			}),
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITHUB, Url: "https://github.com/example/app/commit/0a1b2c3d",
				CommitSha: "0a1b2c3d", Branch: "main", CommitTitle: "Add the orders table",
				AuthorName: "Alex Doe", AuthorLogin: "alexdoe",
				PullRequest: &v1pb.Release_VCSSource_PullRequest{Url: "https://github.com/example/app/pull/42", Number: 42},
			},
		},
		{
			name:     "GitHub merge-commit push takes the PR title from the message body",
			platform: world.GitHub,
			env: with(githubPush, map[string]string{
				githubEventPayload: `{"head_commit":{"message":"Merge pull request #42 from example/feature/orders\n\nAdd the orders table","author":{"name":"Alex Doe","username":"alexdoe"}}}`,
			}),
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITHUB, Url: "https://github.com/example/app/commit/0a1b2c3d",
				CommitSha: "0a1b2c3d", Branch: "main", CommitTitle: "Merge pull request #42 from example/feature/orders",
				AuthorName: "Alex Doe", AuthorLogin: "alexdoe",
				PullRequest: &v1pb.Release_VCSSource_PullRequest{Url: "https://github.com/example/app/pull/42", Number: 42, Title: "Add the orders table"},
			},
		},
		{
			name:     "GitHub rebase push has a commit title and no PR",
			platform: world.GitHub,
			env: with(githubPush, map[string]string{
				githubEventPayload: `{"head_commit":{"message":"Add the orders index\n\nbody","author":{"name":"Alex Doe","username":"alexdoe"}}}`,
			}),
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITHUB, Url: "https://github.com/example/app/commit/0a1b2c3d",
				CommitSha: "0a1b2c3d", Branch: "main", CommitTitle: "Add the orders index",
				AuthorName: "Alex Doe", AuthorLogin: "alexdoe",
			},
		},
		{
			name:     "GitHub tag push has no branch",
			platform: world.GitHub,
			env: with(githubPush, map[string]string{
				"GITHUB_REF_NAME": "v1.2.0",
				"GITHUB_REF_TYPE": "tag",
			}),
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITHUB, Url: "https://github.com/example/app/commit/0a1b2c3d", CommitSha: "0a1b2c3d",
			},
		},
		{
			name:     "GitHub merge queue run has no branch",
			platform: world.GitHub,
			env: with(githubPush, map[string]string{
				"GITHUB_EVENT_NAME": "merge_group",
				"GITHUB_REF_NAME":   "gh-readonly-queue/main/pr-42-0a1b2c3d",
			}),
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITHUB, Url: "https://github.com/example/app/commit/0a1b2c3d", CommitSha: "0a1b2c3d",
			},
		},
		{
			name:     "GitLab tag pipeline has no branch",
			platform: world.GitLab,
			env: map[string]string{
				"CI_PROJECT_URL":     "https://gitlab.example.com/group/app",
				"CI_COMMIT_SHA":      "9f8e7d6c",
				"CI_COMMIT_REF_NAME": "v1.2.0",
				"CI_COMMIT_TITLE":    "Release 1.2.0",
			},
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITLAB, Url: "https://gitlab.example.com/group/app/-/commit/9f8e7d6c",
				CommitSha: "9f8e7d6c", CommitTitle: "Release 1.2.0",
			},
		},
		{
			name:     "Azure tag build has no branch",
			platform: world.AzureDevOps,
			env: map[string]string{
				"SYSTEM_COLLECTIONURI":  "https://dev.azure.com/example/",
				"SYSTEM_TEAMPROJECT":    "shop",
				"BUILD_REPOSITORY_NAME": "app",
				"BUILD_SOURCEVERSION":   "1c2d3e4f",
				"BUILD_SOURCEBRANCH":    "refs/tags/v1.2.0",
			},
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_AZURE_DEVOPS, Url: "https://dev.azure.com/example/shop/_git/app/commit/1c2d3e4f", CommitSha: "1c2d3e4f",
			},
		},
		{
			name:     "GitLab merge request pipeline uses the source branch and the MR",
			platform: world.GitLab,
			env: map[string]string{
				"CI_PROJECT_URL":                      "https://gitlab.example.com/group/app",
				"CI_COMMIT_SHA":                       "9f8e7d6c",
				"CI_COMMIT_REF_NAME":                  "refs/merge-requests/7/head",
				"CI_MERGE_REQUEST_SOURCE_BRANCH_NAME": "feature/orders",
				"CI_COMMIT_TITLE":                     "Add the orders table",
				"CI_COMMIT_MESSAGE":                   "Add the orders table",
				"CI_COMMIT_AUTHOR":                    "Alex Doe <alex@example.com>",
				"CI_MERGE_REQUEST_IID":                "7",
				"CI_MERGE_REQUEST_TITLE":              "Orders table",
			},
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITLAB, Url: "https://gitlab.example.com/group/app/-/commit/9f8e7d6c",
				CommitSha: "9f8e7d6c", Branch: "feature/orders", CommitTitle: "Add the orders table", AuthorName: "Alex Doe",
				PullRequest: &v1pb.Release_VCSSource_PullRequest{Url: "https://gitlab.example.com/group/app/-/merge_requests/7", Number: 7, Title: "Orders table"},
			},
		},
		{
			name:     "GitLab push takes the MR number from the merge commit message",
			platform: world.GitLab,
			env: map[string]string{
				"CI_PROJECT_URL":     "https://gitlab.example.com/group/app",
				"CI_COMMIT_SHA":      "9f8e7d6c",
				"CI_COMMIT_REF_NAME": "main",
				"CI_COMMIT_BRANCH":   "main",
				"CI_COMMIT_TITLE":    "Merge branch 'feature/orders' into 'main'",
				"CI_COMMIT_MESSAGE":  "Merge branch 'feature/orders' into 'main'\n\nOrders table\n\nSee merge request group/app!7",
				"CI_COMMIT_AUTHOR":   "Alex Doe <alex@example.com>",
			},
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITLAB, Url: "https://gitlab.example.com/group/app/-/commit/9f8e7d6c",
				CommitSha: "9f8e7d6c", Branch: "main", CommitTitle: "Merge branch 'feature/orders' into 'main'", AuthorName: "Alex Doe",
				PullRequest: &v1pb.Release_VCSSource_PullRequest{Url: "https://gitlab.example.com/group/app/-/merge_requests/7", Number: 7},
			},
		},
		{
			name:     "Bitbucket push has no title or author",
			platform: world.Bitbucket,
			env: map[string]string{
				"BITBUCKET_GIT_HTTP_ORIGIN": "https://bitbucket.org/example/app",
				"BITBUCKET_COMMIT":          "5e6f7a8b",
				"BITBUCKET_BRANCH":          "main",
			},
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_BITBUCKET, Url: "https://bitbucket.org/example/app/commits/5e6f7a8b",
				CommitSha: "5e6f7a8b", Branch: "main",
			},
		},
		{
			name:     "Bitbucket pull request pipeline has the PR number",
			platform: world.Bitbucket,
			env: map[string]string{
				"BITBUCKET_GIT_HTTP_ORIGIN": "https://bitbucket.org/example/app",
				"BITBUCKET_COMMIT":          "5e6f7a8b",
				"BITBUCKET_BRANCH":          "feature/orders",
				"BITBUCKET_PR_ID":           "12",
			},
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_BITBUCKET, Url: "https://bitbucket.org/example/app/commits/5e6f7a8b",
				CommitSha: "5e6f7a8b", Branch: "feature/orders",
				PullRequest: &v1pb.Release_VCSSource_PullRequest{Url: "https://bitbucket.org/example/app/pull-requests/12", Number: 12},
			},
		},
		{
			name:     "Azure PR build keeps a slashed source branch whole",
			platform: world.AzureDevOps,
			env: map[string]string{
				"SYSTEM_COLLECTIONURI":             "https://dev.azure.com/example/",
				"SYSTEM_TEAMPROJECT":               "shop",
				"BUILD_REPOSITORY_NAME":            "app",
				"BUILD_SOURCEVERSION":              "1c2d3e4f",
				"BUILD_SOURCEBRANCH":               "refs/pull/31/merge",
				"BUILD_SOURCEBRANCHNAME":           "merge",
				"SYSTEM_PULLREQUEST_SOURCEBRANCH":  "refs/heads/feature/orders",
				"SYSTEM_PULLREQUEST_PULLREQUESTID": "31",
				"BUILD_SOURCEVERSIONMESSAGE":       "Merge pull request 31 from feature/orders into main",
				"BUILD_REQUESTEDFOR":               "Alex Doe",
			},
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_AZURE_DEVOPS, Url: "https://dev.azure.com/example/shop/_git/app/commit/1c2d3e4f",
				CommitSha: "1c2d3e4f", Branch: "feature/orders", CommitTitle: "Merge pull request 31 from feature/orders into main", AuthorName: "Alex Doe",
				PullRequest: &v1pb.Release_VCSSource_PullRequest{Url: "https://dev.azure.com/example/shop/_git/app/pullrequest/31", Number: 31},
			},
		},
		{
			name:     "Azure push takes the PR number from the completion message",
			platform: world.AzureDevOps,
			env: map[string]string{
				"SYSTEM_COLLECTIONURI":       "https://dev.azure.com/example/",
				"SYSTEM_TEAMPROJECT":         "shop",
				"BUILD_REPOSITORY_NAME":      "app",
				"BUILD_SOURCEVERSION":        "1c2d3e4f",
				"BUILD_SOURCEBRANCH":         "refs/heads/main",
				"BUILD_SOURCEVERSIONMESSAGE": "Merged PR 31: Add the orders table\n\nbody",
				"BUILD_REQUESTEDFOR":         "Alex Doe",
			},
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_AZURE_DEVOPS, Url: "https://dev.azure.com/example/shop/_git/app/commit/1c2d3e4f",
				CommitSha: "1c2d3e4f", Branch: "main", CommitTitle: "Add the orders table", AuthorName: "Alex Doe",
				PullRequest: &v1pb.Release_VCSSource_PullRequest{Url: "https://dev.azure.com/example/shop/_git/app/pullrequest/31", Number: 31},
			},
		},
		{
			name:     "undetected platform without flags has no source",
			platform: world.LocalPlatform,
			want:     nil,
		},
		{
			name:     "undetected platform takes what the flags pass",
			platform: world.LocalPlatform,
			flags: func(w *world.World) {
				w.SourceTitle = "Add the orders table"
				w.SourceURL = "https://git.example.com/app/commit/77aa"
				w.SourceAuthor = "Alex Doe"
				w.SourceRef = "main"
			},
			want: &v1pb.Release_VCSSource{
				Url: "https://git.example.com/app/commit/77aa", CommitTitle: "Add the orders table", AuthorName: "Alex Doe", Branch: "main",
			},
		},
		{
			name:     "flags win over detection",
			platform: world.GitHub,
			env: with(githubPush, map[string]string{
				githubEventPayload: `{"head_commit":{"message":"Add the orders index","author":{"name":"Alex Doe","username":"alexdoe"}}}`,
			}),
			flags: func(w *world.World) {
				w.SourceTitle = "Release train 12"
				w.SourceRef = "release/12"
				w.SourceAuthor = "Release Bot"
				w.SourceURL = "https://github.com/example/app/commit/ffff"
			},
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITHUB, Url: "https://github.com/example/app/commit/ffff",
				Branch: "release/12", CommitTitle: "Release train 12", AuthorName: "Release Bot",
			},
		},
		{
			name:     "a URL flag drops the detected pull request, title, author and branch",
			platform: world.GitHub,
			env: with(githubPush, map[string]string{
				"GITHUB_EVENT_NAME": "pull_request",
				githubEventPayload:  `{"pull_request":{"number":42,"title":"Add the orders table","html_url":"https://github.com/example/app/pull/42","user":{"login":"alexdoe"}}}`,
			}),
			flags: func(w *world.World) { w.SourceURL = "https://github.com/example/app/pull/99" },
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITHUB, Url: "https://github.com/example/app/pull/99",
			},
		},
		{
			name:     "a title flag replaces a detected pull request's title",
			platform: world.GitHub,
			env: with(githubPush, map[string]string{
				"GITHUB_EVENT_NAME": "pull_request",
				githubEventPayload:  `{"pull_request":{"number":42,"title":"Add the orders table","html_url":"https://github.com/example/app/pull/42","user":{"login":"alexdoe"}}}`,
			}),
			flags: func(w *world.World) { w.SourceTitle = "Release train 12" },
			want: &v1pb.Release_VCSSource{
				VcsType: v1pb.VCSType_GITHUB, Url: "https://github.com/example/app/commit/0a1b2c3d",
				CommitSha: "0a1b2c3d", Branch: "main", CommitTitle: "Release train 12", AuthorLogin: "alexdoe",
				PullRequest: &v1pb.Release_VCSSource_PullRequest{Url: "https://github.com/example/app/pull/42", Number: 42, Title: "Release train 12"},
			},
		},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			for _, key := range vcsSourceEnvKeys {
				t.Setenv(key, "")
			}
			for key, value := range tc.env {
				if key == githubEventPayload {
					if value != "" {
						eventPath := filepath.Join(t.TempDir(), "event.json")
						require.NoError(t, os.WriteFile(eventPath, []byte(value), 0600))
						t.Setenv("GITHUB_EVENT_PATH", eventPath)
					}
					continue
				}
				t.Setenv(key, value)
			}
			w := world.NewWorld()
			w.Platform = tc.platform
			if tc.flags != nil {
				tc.flags(w)
			}
			got := getVCSSource(w)
			require.True(t, proto.Equal(tc.want, got), "want %v\ngot  %v", tc.want, got)
		})
	}
}

// A case's GitHub event payload, written to the file GITHUB_EVENT_PATH names.
const githubEventPayload = "github event payload"

// Every variable getVCSSource reads, cleared per case so the runner's own CI
// environment cannot leak in.
var vcsSourceEnvKeys = []string{
	"GITHUB_SERVER_URL", "GITHUB_REPOSITORY", "GITHUB_SHA", "GITHUB_EVENT_NAME", "GITHUB_EVENT_PATH", "GITHUB_REF_NAME", "GITHUB_REF_TYPE", "GITHUB_HEAD_REF",
	"CI_PROJECT_URL", "CI_COMMIT_SHA", "CI_COMMIT_REF_NAME", "CI_COMMIT_BRANCH", "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME", "CI_COMMIT_TITLE", "CI_COMMIT_MESSAGE", "CI_COMMIT_AUTHOR", "CI_MERGE_REQUEST_IID", "CI_MERGE_REQUEST_TITLE",
	"BITBUCKET_GIT_HTTP_ORIGIN", "BITBUCKET_COMMIT", "BITBUCKET_BRANCH", "BITBUCKET_PR_ID",
	"SYSTEM_COLLECTIONURI", "SYSTEM_TEAMPROJECT", "BUILD_REPOSITORY_NAME", "BUILD_SOURCEVERSION", "BUILD_SOURCEBRANCH", "BUILD_SOURCEBRANCHNAME", "SYSTEM_PULLREQUEST_SOURCEBRANCH", "SYSTEM_PULLREQUEST_PULLREQUESTID", "BUILD_SOURCEVERSIONMESSAGE", "BUILD_REQUESTEDFOR",
}