github.com/ouraigua/jenkins-library@v0.0.0-20231028010029-fbeaf2f3aa9b/pkg/orchestrator/gitHubActions.go (about)

     1  package orchestrator
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"strconv"
     9  	"strings"
    10  	"sync"
    11  	"time"
    12  
    13  	piperGithub "github.com/SAP/jenkins-library/pkg/github"
    14  	"github.com/SAP/jenkins-library/pkg/log"
    15  	"github.com/SAP/jenkins-library/pkg/piperutils"
    16  	"github.com/google/go-github/v45/github"
    17  	"github.com/pkg/errors"
    18  	"golang.org/x/sync/errgroup"
    19  )
    20  
    21  type GitHubActionsConfigProvider struct {
    22  	client      *github.Client
    23  	ctx         context.Context
    24  	owner       string
    25  	repo        string
    26  	runData     run
    27  	jobs        []job
    28  	jobsFetched bool
    29  	currentJob  job
    30  }
    31  
    32  type run struct {
    33  	fetched   bool
    34  	Status    string    `json:"status"`
    35  	StartedAt time.Time `json:"run_started_at"`
    36  }
    37  
    38  type job struct {
    39  	ID      int64  `json:"id"`
    40  	Name    string `json:"name"`
    41  	HtmlURL string `json:"html_url"`
    42  }
    43  
    44  type fullLog struct {
    45  	sync.Mutex
    46  	b [][]byte
    47  }
    48  
    49  // InitOrchestratorProvider initializes http client for GitHubActionsDevopsConfigProvider
    50  func (g *GitHubActionsConfigProvider) InitOrchestratorProvider(settings *OrchestratorSettings) {
    51  	var err error
    52  	g.ctx, g.client, err = piperGithub.NewClientBuilder(settings.GitHubToken, getEnv("GITHUB_API_URL", "")).Build()
    53  	if err != nil {
    54  		log.Entry().Errorf("failed to create github client: %v", err)
    55  		return
    56  	}
    57  
    58  	g.owner, g.repo = getOwnerAndRepoNames()
    59  
    60  	log.Entry().Debug("Successfully initialized GitHubActions config provider")
    61  }
    62  
    63  func (g *GitHubActionsConfigProvider) OrchestratorVersion() string {
    64  	log.Entry().Debugf("OrchestratorVersion() for GitHub Actions is not applicable.")
    65  	return "n/a"
    66  }
    67  
    68  func (g *GitHubActionsConfigProvider) OrchestratorType() string {
    69  	return "GitHubActions"
    70  }
    71  
    72  // GetBuildStatus returns current run status
    73  func (g *GitHubActionsConfigProvider) GetBuildStatus() string {
    74  	g.fetchRunData()
    75  	switch g.runData.Status {
    76  	case "success":
    77  		return BuildStatusSuccess
    78  	case "cancelled":
    79  		return BuildStatusAborted
    80  	case "in_progress":
    81  		return BuildStatusInProgress
    82  	default:
    83  		return BuildStatusFailure
    84  	}
    85  }
    86  
    87  // GetLog returns the whole logfile for the current pipeline run
    88  func (g *GitHubActionsConfigProvider) GetLog() ([]byte, error) {
    89  	if err := g.fetchJobs(); err != nil {
    90  		return nil, err
    91  	}
    92  	// Ignore the last stage (job) as it is not possible in GitHub to fetch logs for a running job.
    93  	jobs := g.jobs[:len(g.jobs)-1]
    94  
    95  	fullLogs := fullLog{b: make([][]byte, len(jobs))}
    96  	wg := errgroup.Group{}
    97  	wg.SetLimit(10)
    98  	for i := range jobs {
    99  		i := i // https://golang.org/doc/faq#closures_and_goroutines
   100  		wg.Go(func() error {
   101  			_, resp, err := g.client.Actions.GetWorkflowJobLogs(g.ctx, g.owner, g.repo, jobs[i].ID, true)
   102  			if err != nil {
   103  				return errors.Wrap(err, "fetching job logs failed")
   104  			}
   105  			defer resp.Body.Close()
   106  
   107  			b, err := io.ReadAll(resp.Body)
   108  			if err != nil {
   109  				return errors.Wrap(err, "failed to read response body")
   110  			}
   111  
   112  			fullLogs.Lock()
   113  			fullLogs.b[i] = b
   114  			fullLogs.Unlock()
   115  
   116  			return nil
   117  		})
   118  	}
   119  	if err := wg.Wait(); err != nil {
   120  		return nil, errors.Wrap(err, "failed to fetch all logs")
   121  	}
   122  
   123  	return bytes.Join(fullLogs.b, []byte("")), nil
   124  }
   125  
   126  // GetBuildID returns current run ID
   127  func (g *GitHubActionsConfigProvider) GetBuildID() string {
   128  	return getEnv("GITHUB_RUN_ID", "n/a")
   129  }
   130  
   131  func (g *GitHubActionsConfigProvider) GetChangeSet() []ChangeSet {
   132  	log.Entry().Debug("GetChangeSet for GitHubActions not implemented")
   133  	return []ChangeSet{}
   134  }
   135  
   136  // GetPipelineStartTime returns the pipeline start time in UTC
   137  func (g *GitHubActionsConfigProvider) GetPipelineStartTime() time.Time {
   138  	g.fetchRunData()
   139  	return g.runData.StartedAt.UTC()
   140  }
   141  
   142  // GetStageName returns the human-readable name given to a stage.
   143  func (g *GitHubActionsConfigProvider) GetStageName() string {
   144  	return getEnv("GITHUB_JOB", "unknown")
   145  }
   146  
   147  // GetBuildReason returns the reason of workflow trigger.
   148  // BuildReasons are unified with AzureDevOps build reasons, see
   149  // https://docs.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#build-variables-devops-services
   150  func (g *GitHubActionsConfigProvider) GetBuildReason() string {
   151  	switch getEnv("GITHUB_EVENT_NAME", "") {
   152  	case "workflow_dispatch":
   153  		return BuildReasonManual
   154  	case "schedule":
   155  		return BuildReasonSchedule
   156  	case "pull_request":
   157  		return BuildReasonPullRequest
   158  	case "workflow_call":
   159  		return BuildReasonResourceTrigger
   160  	case "push":
   161  		return BuildReasonIndividualCI
   162  	default:
   163  		return BuildReasonUnknown
   164  	}
   165  }
   166  
   167  // GetBranch returns the source branch name, e.g. main
   168  func (g *GitHubActionsConfigProvider) GetBranch() string {
   169  	return getEnv("GITHUB_REF_NAME", "n/a")
   170  }
   171  
   172  // GetReference return the git reference. For example, refs/heads/your_branch_name
   173  func (g *GitHubActionsConfigProvider) GetReference() string {
   174  	return getEnv("GITHUB_REF", "n/a")
   175  }
   176  
   177  // GetBuildURL returns the builds URL. For example, https://github.com/SAP/jenkins-library/actions/runs/5815297487
   178  func (g *GitHubActionsConfigProvider) GetBuildURL() string {
   179  	return g.GetRepoURL() + "/actions/runs/" + g.GetBuildID()
   180  }
   181  
   182  // GetJobURL returns the current job HTML URL (not API URL).
   183  // For example, https://github.com/SAP/jenkins-library/actions/runs/123456/jobs/7654321
   184  func (g *GitHubActionsConfigProvider) GetJobURL() string {
   185  	// We need to query the GitHub API here because the environment variable GITHUB_JOB returns
   186  	// the name of the job, not a numeric ID (which we need to form the URL)
   187  	g.guessCurrentJob()
   188  	return g.currentJob.HtmlURL
   189  }
   190  
   191  // GetJobName returns the current workflow name. For example, "Piper workflow"
   192  func (g *GitHubActionsConfigProvider) GetJobName() string {
   193  	return getEnv("GITHUB_WORKFLOW", "unknown")
   194  }
   195  
   196  // GetCommit returns the commit SHA that triggered the workflow. For example, ffac537e6cbbf934b08745a378932722df287a53
   197  func (g *GitHubActionsConfigProvider) GetCommit() string {
   198  	return getEnv("GITHUB_SHA", "n/a")
   199  }
   200  
   201  // GetRepoURL returns full url to repository. For example, https://github.com/SAP/jenkins-library
   202  func (g *GitHubActionsConfigProvider) GetRepoURL() string {
   203  	return getEnv("GITHUB_SERVER_URL", "n/a") + "/" + getEnv("GITHUB_REPOSITORY", "n/a")
   204  }
   205  
   206  // GetPullRequestConfig returns pull request configuration
   207  func (g *GitHubActionsConfigProvider) GetPullRequestConfig() PullRequestConfig {
   208  	// See https://docs.github.com/en/enterprise-server@3.6/actions/learn-github-actions/variables#default-environment-variables
   209  	githubRef := getEnv("GITHUB_REF", "n/a")
   210  	prNumber := strings.TrimSuffix(strings.TrimPrefix(githubRef, "refs/pull/"), "/merge")
   211  	return PullRequestConfig{
   212  		Branch: getEnv("GITHUB_HEAD_REF", "n/a"),
   213  		Base:   getEnv("GITHUB_BASE_REF", "n/a"),
   214  		Key:    prNumber,
   215  	}
   216  }
   217  
   218  // IsPullRequest indicates whether the current build is triggered by a PR
   219  func (g *GitHubActionsConfigProvider) IsPullRequest() bool {
   220  	return truthy("GITHUB_HEAD_REF")
   221  }
   222  
   223  func isGitHubActions() bool {
   224  	envVars := []string{"GITHUB_ACTION", "GITHUB_ACTIONS"}
   225  	return areIndicatingEnvVarsSet(envVars)
   226  }
   227  
   228  // actionsURL returns URL to actions resource. For example,
   229  // https://api.github.com/repos/SAP/jenkins-library/actions
   230  func actionsURL() string {
   231  	return getEnv("GITHUB_API_URL", "") + "/repos/" + getEnv("GITHUB_REPOSITORY", "") + "/actions"
   232  }
   233  
   234  func (g *GitHubActionsConfigProvider) fetchRunData() {
   235  	if g.runData.fetched {
   236  		return
   237  	}
   238  
   239  	runId, err := g.runIdInt64()
   240  	if err != nil {
   241  		log.Entry().Errorf("fetchRunData: %s", err)
   242  	}
   243  
   244  	runData, resp, err := g.client.Actions.GetWorkflowRunByID(g.ctx, g.owner, g.repo, runId)
   245  	if err != nil || resp.StatusCode != 200 {
   246  		log.Entry().Errorf("failed to get API data: %s", err)
   247  		return
   248  	}
   249  
   250  	g.runData = convertRunData(runData)
   251  	g.runData.fetched = true
   252  }
   253  
   254  func convertRunData(runData *github.WorkflowRun) run {
   255  	startedAtTs := piperutils.SafeDereference(runData.RunStartedAt)
   256  	return run{
   257  		Status:    piperutils.SafeDereference(runData.Status),
   258  		StartedAt: startedAtTs.Time,
   259  	}
   260  }
   261  
   262  func (g *GitHubActionsConfigProvider) fetchJobs() error {
   263  	if g.jobsFetched {
   264  		return nil
   265  	}
   266  
   267  	runId, err := g.runIdInt64()
   268  	if err != nil {
   269  		return err
   270  	}
   271  
   272  	jobs, resp, err := g.client.Actions.ListWorkflowJobs(g.ctx, g.owner, g.repo, runId, nil)
   273  	if err != nil || resp.StatusCode != 200 {
   274  		return errors.Wrap(err, "failed to get API data")
   275  	}
   276  	if len(jobs.Jobs) == 0 {
   277  		return fmt.Errorf("no jobs found in response")
   278  	}
   279  
   280  	g.jobs = convertJobs(jobs.Jobs)
   281  	g.jobsFetched = true
   282  
   283  	return nil
   284  }
   285  
   286  func convertJobs(jobs []*github.WorkflowJob) []job {
   287  	result := make([]job, 0, len(jobs))
   288  	for _, j := range jobs {
   289  		result = append(result, job{
   290  			ID:      j.GetID(),
   291  			Name:    j.GetName(),
   292  			HtmlURL: j.GetHTMLURL(),
   293  		})
   294  	}
   295  	return result
   296  }
   297  
   298  func (g *GitHubActionsConfigProvider) guessCurrentJob() {
   299  	// check if the current job has already been guessed
   300  	if g.currentJob.ID != 0 {
   301  		return
   302  	}
   303  
   304  	// fetch jobs if they haven't been fetched yet
   305  	if err := g.fetchJobs(); err != nil {
   306  		log.Entry().Errorf("failed to fetch jobs: %s", err)
   307  		g.jobs = []job{}
   308  		return
   309  	}
   310  
   311  	targetJobName := getEnv("GITHUB_JOB", "unknown")
   312  	log.Entry().Debugf("looking for job '%s' in jobs list: %v", targetJobName, g.jobs)
   313  	for _, j := range g.jobs {
   314  		// j.Name may be something like "piper / Init / Init"
   315  		// but GITHUB_JOB env may contain only "Init"
   316  		if strings.HasSuffix(j.Name, targetJobName) {
   317  			log.Entry().Debugf("current job id: %d", j.ID)
   318  			g.currentJob = j
   319  			return
   320  		}
   321  	}
   322  }
   323  
   324  func (g *GitHubActionsConfigProvider) runIdInt64() (int64, error) {
   325  	strRunId := g.GetBuildID()
   326  	runId, err := strconv.ParseInt(strRunId, 10, 64)
   327  	if err != nil {
   328  		return 0, errors.Wrapf(err, "invalid GITHUB_RUN_ID value %s: %s", strRunId, err)
   329  	}
   330  
   331  	return runId, nil
   332  }
   333  
   334  func getOwnerAndRepoNames() (string, string) {
   335  	ownerAndRepo := getEnv("GITHUB_REPOSITORY", "")
   336  	s := strings.Split(ownerAndRepo, "/")
   337  	if len(s) != 2 {
   338  		log.Entry().Errorf("unable to determine owner and repo: invalid value of GITHUB_REPOSITORY envvar: %s", ownerAndRepo)
   339  		return "", ""
   340  	}
   341  
   342  	return s[0], s[1]
   343  }