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 }