Ctrl K
ComunidadGuías…

action/command/vcs_user.go

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

Archivo original ↗Preguntar a KiBot
package command

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"

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

// readGitHubEvent decodes the payload of the event that triggered the
// workflow, which GitHub writes to the file GITHUB_EVENT_PATH names.
func readGitHubEvent(event any) bool {
	eventPath := os.Getenv("GITHUB_EVENT_PATH")
	if eventPath == "" {
		return false
	}
	data, err := os.ReadFile(eventPath)
	if err != nil {
		return false
	}
	return json.Unmarshal(data, event) == nil
}

var gitLabBotUserNameRE = regexp.MustCompile(`^(project|group)_\d+_bot_[a-z0-9]+$`)

func getVCSUser(platform world.JobPlatform) *v1pb.VCSUser {
	switch platform {
	case world.GitHub:
		return getGitHubVCSUser()
	case world.GitLab:
		return getGitLabVCSUser()
	case world.Bitbucket:
		return getBitbucketVCSUser()
	case world.AzureDevOps:
		return getAzureDevOpsVCSUser()
	default:
		return nil
	}
}

func getGitHubVCSUser() *v1pb.VCSUser {
	eventName := os.Getenv("GITHUB_EVENT_NAME")
	if eventName != "pull_request" && eventName != "pull_request_target" {
		return nil
	}
	var event struct {
		PullRequest struct {
			User struct {
				ID    int64  `json:"id"`
				Login string `json:"login"`
				Name  string `json:"name"`
				Type  string `json:"type"`
			} `json:"user"`
		} `json:"pull_request"`
	}
	if !readGitHubEvent(&event) {
		return nil
	}

	user := event.PullRequest.User
	if user.ID == 0 || isBotUser(user.Type, user.Login) {
		return nil
	}
	return &v1pb.VCSUser{
		VcsType:     v1pb.VCSType_GITHUB,
		UserId:      strconv.FormatInt(user.ID, 10),
		UserName:    user.Login,
		DisplayName: user.Name,
	}
}

func getGitLabVCSUser() *v1pb.VCSUser {
	projectID := os.Getenv("CI_MERGE_REQUEST_PROJECT_ID")
	mergeRequestIID := os.Getenv("CI_MERGE_REQUEST_IID")
	apiURL := os.Getenv("CI_API_V4_URL")
	jobToken := os.Getenv("CI_JOB_TOKEN")
	if projectID == "" || mergeRequestIID == "" || apiURL == "" || jobToken == "" {
		return nil
	}

	requestURL, err := buildGitLabMergeRequestURL(apiURL, projectID, mergeRequestIID)
	if err != nil {
		return nil
	}
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
	if err != nil {
		return nil
	}
	req.Header.Set("Accept", "application/json")
	req.Header.Set("JOB-TOKEN", jobToken)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil
	}

	data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		slog.Warn("failed to read GitLab merge request for VCS user attribution", "error", err)
		return nil
	}
	var mergeRequest struct {
		Author struct {
			ID       int64  `json:"id"`
			Username string `json:"username"`
			Name     string `json:"name"`
		} `json:"author"`
	}
	if err := json.Unmarshal(data, &mergeRequest); err != nil {
		slog.Warn("failed to parse GitLab merge request for VCS user attribution", "error", err)
		return nil
	}

	author := mergeRequest.Author
	if author.ID == 0 || isGitLabBotUser(author.Username) {
		return nil
	}
	return &v1pb.VCSUser{
		VcsType:     v1pb.VCSType_GITLAB,
		UserId:      strconv.FormatInt(author.ID, 10),
		UserName:    author.Username,
		DisplayName: author.Name,
	}
}

func getBitbucketVCSUser() *v1pb.VCSUser {
	pullRequestID := os.Getenv("BITBUCKET_PR_ID")
	if pullRequestID == "" {
		return nil
	}
	workspace, repoSlug := getBitbucketRepository()
	if workspace == "" || repoSlug == "" {
		return nil
	}

	apiURL := getBitbucketAPIBaseURL()
	requestURL, err := buildBitbucketPullRequestURL(apiURL, workspace, repoSlug, pullRequestID)
	if err != nil {
		return nil
	}
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
	if err != nil {
		return nil
	}
	req.Header.Set("Accept", "application/json")

	resp, err := bitbucket.NewHTTPClient(apiURL).Do(req)
	if err != nil {
		return nil
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil
	}

	data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		slog.Warn("failed to read Bitbucket pull request for VCS user attribution", "error", err)
		return nil
	}
	var pullRequest struct {
		Author struct {
			AccountID   string `json:"account_id"`
			UUID        string `json:"uuid"`
			Nickname    string `json:"nickname"`
			DisplayName string `json:"display_name"`
			Type        string `json:"type"`
		} `json:"author"`
	}
	if err := json.Unmarshal(data, &pullRequest); err != nil {
		slog.Warn("failed to parse Bitbucket pull request for VCS user attribution", "error", err)
		return nil
	}

	author := pullRequest.Author
	userID := author.AccountID
	if userID == "" {
		userID = strings.Trim(author.UUID, "{}")
	}
	if userID == "" || isBitbucketBotUser(author.Type, author.Nickname) {
		return nil
	}
	return &v1pb.VCSUser{
		VcsType:     v1pb.VCSType_BITBUCKET,
		UserId:      userID,
		UserName:    author.Nickname,
		DisplayName: author.DisplayName,
	}
}

func getAzureDevOpsVCSUser() *v1pb.VCSUser {
	pullRequestID := os.Getenv("SYSTEM_PULLREQUEST_PULLREQUESTID")
	collectionURI := firstNonEmpty(os.Getenv("SYSTEM_COLLECTIONURI"), os.Getenv("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI"))
	accessToken := os.Getenv("SYSTEM_ACCESSTOKEN")
	if pullRequestID == "" || collectionURI == "" || accessToken == "" {
		return nil
	}

	requestURL, err := buildAzureDevOpsPullRequestURL(collectionURI, pullRequestID)
	if err != nil {
		slog.Warn("failed to build Azure DevOps pull request URL for VCS user attribution", "error", err)
		return nil
	}
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
	if err != nil {
		slog.Warn("failed to create Azure DevOps pull request request for VCS user attribution", "error", err)
		return nil
	}
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Authorization", "Bearer "+accessToken)

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		slog.Warn("failed to get Azure DevOps pull request for VCS user attribution", "error", err)
		return nil
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		slog.Warn("failed to get Azure DevOps pull request for VCS user attribution", "status", resp.Status)
		return nil
	}

	data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		slog.Warn("failed to read Azure DevOps pull request for VCS user attribution", "error", err)
		return nil
	}
	var pullRequest struct {
		CreatedBy struct {
			ID          string `json:"id"`
			UniqueName  string `json:"uniqueName"`
			DisplayName string `json:"displayName"`
			Descriptor  string `json:"descriptor"`
		} `json:"createdBy"`
	}
	if err := json.Unmarshal(data, &pullRequest); err != nil {
		slog.Warn("failed to parse Azure DevOps pull request for VCS user attribution", "error", err)
		return nil
	}

	creator := pullRequest.CreatedBy
	if creator.ID == "" || isAzureDevOpsBotUser(creator.Descriptor, creator.UniqueName) {
		return nil
	}
	return &v1pb.VCSUser{
		VcsType:     v1pb.VCSType_AZURE_DEVOPS,
		UserId:      creator.ID,
		UserName:    creator.UniqueName,
		DisplayName: creator.DisplayName,
	}
}

func buildGitLabMergeRequestURL(apiURL, projectID, mergeRequestIID string) (string, error) {
	parsedURL, err := url.Parse(apiURL)
	if err != nil {
		return "", err
	}
	parsedURL.Path = strings.TrimRight(parsedURL.Path, "/") + "/projects/" + url.PathEscape(projectID) + "/merge_requests/" + url.PathEscape(mergeRequestIID)
	parsedURL.RawQuery = ""
	parsedURL.Fragment = ""
	return parsedURL.String(), nil
}

func getBitbucketRepository() (string, string) {
	if fullName := os.Getenv("BITBUCKET_REPO_FULL_NAME"); fullName != "" {
		parts := strings.SplitN(fullName, "/", 2)
		if len(parts) == 2 {
			return parts[0], parts[1]
		}
	}
	return firstNonEmpty(os.Getenv("BITBUCKET_WORKSPACE"), os.Getenv("BITBUCKET_REPO_OWNER")), os.Getenv("BITBUCKET_REPO_SLUG")
}

func getBitbucketAPIBaseURL() string {
	if apiURL := os.Getenv("BYTEBASE_BITBUCKET_API_BASE_URL"); apiURL != "" {
		return apiURL
	}
	return bitbucket.APIBaseURL
}

func buildBitbucketPullRequestURL(apiURL, workspace, repoSlug, pullRequestID string) (string, error) {
	parsedURL, err := url.Parse(apiURL)
	if err != nil {
		return "", err
	}
	parsedURL.Path = strings.TrimRight(parsedURL.Path, "/") + "/repositories/" + url.PathEscape(workspace) + "/" + url.PathEscape(repoSlug) + "/pullrequests/" + url.PathEscape(pullRequestID)
	parsedURL.RawQuery = ""
	parsedURL.Fragment = ""
	return parsedURL.String(), nil
}

func buildAzureDevOpsPullRequestURL(collectionURI, pullRequestID string) (string, error) {
	parsedURL, err := url.Parse(collectionURI)
	if err != nil {
		return "", err
	}
	parsedURL.Path = strings.TrimRight(parsedURL.Path, "/") + "/_apis/git/pullrequests/" + url.PathEscape(pullRequestID)
	parsedURL.RawQuery = url.Values{"api-version": {"7.1"}}.Encode()
	parsedURL.Fragment = ""
	return parsedURL.String(), nil
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if value != "" {
			return value
		}
	}
	return ""
}

func isGitLabBotUser(userName string) bool {
	lowerUserName := strings.ToLower(userName)
	return isBotUser("", lowerUserName) || gitLabBotUserNameRE.MatchString(lowerUserName)
}

func isBitbucketBotUser(userType, userName string) bool {
	return isBotUser(userType, userName) || strings.EqualFold(userType, "app") || strings.EqualFold(userType, "app_user")
}

func isAzureDevOpsBotUser(descriptor, userName string) bool {
	lowerDescriptor := strings.ToLower(descriptor)
	return strings.HasPrefix(lowerDescriptor, "svc.") || strings.HasPrefix(lowerDescriptor, "aadsp.") || isBotUser("", userName)
}

func isBotUser(userType, userName string) bool {
	return strings.EqualFold(userType, "bot") || strings.HasSuffix(strings.ToLower(userName), "[bot]")
}

// The markers a platform writes into the commit that merged a pull request.
// A rebase merge writes none, so its source has no pull request.
var (
	gitHubSquashMergeRE  = regexp.MustCompile(`^(.*?)\s*\(#(\d+)\)$`)
	gitHubMergeCommitRE  = regexp.MustCompile(`^Merge pull request #(\d+) from `)
	gitLabMergeRequestRE = regexp.MustCompile(`(?m)^See merge request \S+!(\d+)\s*$`)
	azureMergedPRRE      = regexp.MustCompile(`^Merged PR (\d+): (.*)$`)
)

// getVCSSource describes the commit the release was read from, from the CI
// environment and the --source-* flags. A flag wins over the detected value.
// It reads only environment variables and, on GitHub, the event payload: the
// action image has no git binary.
func getVCSSource(w *world.World) *v1pb.Release_VCSSource {
	var source *v1pb.Release_VCSSource
	switch w.Platform {
	case world.GitHub:
		source = getGitHubVCSSource()
	case world.GitLab:
		source = getGitLabVCSSource()
	case world.Bitbucket:
		source = getBitbucketVCSSource()
	case world.AzureDevOps:
		source = getAzureDevOpsVCSSource()
	default:
	}
	if w.SourceTitle == "" && w.SourceURL == "" && w.SourceAuthor == "" && w.SourceRef == "" {
		return source
	}
	if source == nil {
		source = &v1pb.Release_VCSSource{}
	}
	// A flag URL names another change, so nothing detected about the change
	// matches it; only the platform stays. The other flags then apply.
	if w.SourceURL != "" {
		source = &v1pb.Release_VCSSource{VcsType: source.VcsType, Url: w.SourceURL}
	}
	// The Source row shows a pull request's title and link over the commit's,
	// so a title flag replaces both titles.
	if w.SourceTitle != "" {
		source.CommitTitle = w.SourceTitle
		if source.PullRequest != nil {
			source.PullRequest.Title = w.SourceTitle
		}
	}
	if w.SourceAuthor != "" {
		source.AuthorName = w.SourceAuthor
	}
	if w.SourceRef != "" {
		source.Branch = w.SourceRef
	}
	return source
}

// githubBranch is the source branch, or empty when the run is on a tag or a
// merge queue's temporary branch. GITHUB_REF_NAME is the synthetic
// "<N>/merge" in a pull request run, so that run reads GITHUB_HEAD_REF.
func githubBranch() string {
	if head := os.Getenv("GITHUB_HEAD_REF"); head != "" {
		return head
	}
	if os.Getenv("GITHUB_EVENT_NAME") == "merge_group" || os.Getenv("GITHUB_REF_TYPE") != "branch" {
		return ""
	}
	return os.Getenv("GITHUB_REF_NAME")
}

func getGitHubVCSSource() *v1pb.Release_VCSSource {
	repoURL := os.Getenv("GITHUB_SERVER_URL") + "/" + os.Getenv("GITHUB_REPOSITORY")
	sha := os.Getenv("GITHUB_SHA")
	source := &v1pb.Release_VCSSource{
		VcsType:   v1pb.VCSType_GITHUB,
		Url:       repoURL + "/commit/" + sha,
		CommitSha: sha,
		Branch:    githubBranch(),
	}

	var event struct {
		PullRequest *struct {
			Number  int64  `json:"number"`
			Title   string `json:"title"`
			HTMLURL string `json:"html_url"`
			User    struct {
				Login string `json:"login"`
			} `json:"user"`
		} `json:"pull_request"`
		HeadCommit *struct {
			Message string `json:"message"`
			Author  struct {
				Name     string `json:"name"`
				Username string `json:"username"`
			} `json:"author"`
		} `json:"head_commit"`
	}
	if !readGitHubEvent(&event) {
		return source
	}
	if pr := event.PullRequest; pr != nil && pr.Number > 0 {
		source.AuthorLogin = pr.User.Login
		source.PullRequest = &v1pb.Release_VCSSource_PullRequest{
			Url:    firstNonEmpty(pr.HTMLURL, fmt.Sprintf("%s/pull/%d", repoURL, pr.Number)),
			Number: pr.Number,
			Title:  pr.Title,
		}
		return source
	}
	if commit := event.HeadCommit; commit != nil {
		source.AuthorName = commit.Author.Name
		source.AuthorLogin = commit.Author.Username
		title, body := splitCommitMessage(commit.Message)
		source.CommitTitle = title
		if m := gitHubSquashMergeRE.FindStringSubmatch(title); m != nil {
			source.CommitTitle = m[1]
			source.PullRequest = gitHubPullRequest(repoURL, m[2], "")
		} else if m := gitHubMergeCommitRE.FindStringSubmatch(title); m != nil {
			// GitHub puts the pull request's title on the merge commit's body.
			source.PullRequest = gitHubPullRequest(repoURL, m[1], firstLine(body))
		}
	}
	return source
}

func gitHubPullRequest(repoURL, number, title string) *v1pb.Release_VCSSource_PullRequest {
	n, err := strconv.ParseInt(number, 10, 64)
	if err != nil {
		return nil
	}
	return &v1pb.Release_VCSSource_PullRequest{Url: fmt.Sprintf("%s/pull/%d", repoURL, n), Number: n, Title: title}
}

func getGitLabVCSSource() *v1pb.Release_VCSSource {
	repoURL := os.Getenv("CI_PROJECT_URL")
	sha := os.Getenv("CI_COMMIT_SHA")
	source := &v1pb.Release_VCSSource{
		VcsType:     v1pb.VCSType_GITLAB,
		Url:         repoURL + "/-/commit/" + sha,
		CommitSha:   sha,
		Branch:      firstNonEmpty(os.Getenv("CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"), os.Getenv("CI_COMMIT_BRANCH")),
		CommitTitle: os.Getenv("CI_COMMIT_TITLE"),
		AuthorName:  authorNameOf(os.Getenv("CI_COMMIT_AUTHOR")),
	}
	number, title := os.Getenv("CI_MERGE_REQUEST_IID"), os.Getenv("CI_MERGE_REQUEST_TITLE")
	if number == "" {
		if m := gitLabMergeRequestRE.FindStringSubmatch(os.Getenv("CI_COMMIT_MESSAGE")); m != nil {
			number = m[1]
		}
	}
	if n, err := strconv.ParseInt(number, 10, 64); err == nil {
		source.PullRequest = &v1pb.Release_VCSSource_PullRequest{
			Url:    fmt.Sprintf("%s/-/merge_requests/%d", repoURL, n),
			Number: n,
			Title:  title,
		}
	}
	return source
}

// Bitbucket Pipelines exposes neither the commit message nor its author.
func getBitbucketVCSSource() *v1pb.Release_VCSSource {
	repoURL := os.Getenv("BITBUCKET_GIT_HTTP_ORIGIN")
	sha := os.Getenv("BITBUCKET_COMMIT")
	source := &v1pb.Release_VCSSource{
		VcsType:   v1pb.VCSType_BITBUCKET,
		Url:       repoURL + "/commits/" + sha,
		CommitSha: sha,
		Branch:    os.Getenv("BITBUCKET_BRANCH"),
	}
	if n, err := strconv.ParseInt(os.Getenv("BITBUCKET_PR_ID"), 10, 64); err == nil {
		source.PullRequest = &v1pb.Release_VCSSource_PullRequest{
			Url:    fmt.Sprintf("%s/pull-requests/%d", repoURL, n),
			Number: n,
		}
	}
	return source
}

func getAzureDevOpsVCSSource() *v1pb.Release_VCSSource {
	repoURL := os.Getenv("SYSTEM_COLLECTIONURI") + os.Getenv("SYSTEM_TEAMPROJECT") + "/_git/" + os.Getenv("BUILD_REPOSITORY_NAME")
	sha := os.Getenv("BUILD_SOURCEVERSION")
	// BUILD_SOURCEBRANCH is "refs/pull/<N>/merge" in a PR build and
	// "refs/tags/<tag>" in a tag build, and BUILD_SOURCEBRANCHNAME keeps only
	// the last segment of "feature/x". Only a refs/heads/ ref is a branch.
	branch := firstNonEmpty(os.Getenv("SYSTEM_PULLREQUEST_SOURCEBRANCH"), os.Getenv("BUILD_SOURCEBRANCH"))
	if name, ok := strings.CutPrefix(branch, "refs/heads/"); ok {
		branch = name
	} else if strings.HasPrefix(branch, "refs/") {
		branch = ""
	}
	title, _ := splitCommitMessage(os.Getenv("BUILD_SOURCEVERSIONMESSAGE"))
	source := &v1pb.Release_VCSSource{
		VcsType:     v1pb.VCSType_AZURE_DEVOPS,
		Url:         repoURL + "/commit/" + sha,
		CommitSha:   sha,
		Branch:      branch,
		CommitTitle: title,
		AuthorName:  os.Getenv("BUILD_REQUESTEDFOR"),
	}
	number := os.Getenv("SYSTEM_PULLREQUEST_PULLREQUESTID")
	if number == "" {
		if m := azureMergedPRRE.FindStringSubmatch(title); m != nil {
			number = m[1]
			source.CommitTitle = m[2]
		}
	}
	if n, err := strconv.ParseInt(number, 10, 64); err == nil {
		source.PullRequest = &v1pb.Release_VCSSource_PullRequest{
			Url:    fmt.Sprintf("%s/pullrequest/%d", repoURL, n),
			Number: n,
		}
	}
	return source
}

// splitCommitMessage returns the message's first line and the rest.
func splitCommitMessage(message string) (string, string) {
	title, body, _ := strings.Cut(strings.TrimSpace(message), "\n")
	return strings.TrimSpace(title), strings.TrimSpace(body)
}

func firstLine(text string) string {
	line, _, _ := strings.Cut(strings.TrimSpace(text), "\n")
	return strings.TrimSpace(line)
}

// authorNameOf drops the email from git's "Name <email>" form.
func authorNameOf(author string) string {
	name, _, _ := strings.Cut(author, " <")
	return strings.TrimSpace(name)
}