|
| 1 | +// Copyright The OpenTelemetry Authors |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +// Package github handles interaction with Github through the [Client]. |
| 16 | +package github |
| 17 | + |
| 18 | +import ( |
| 19 | + "context" |
| 20 | + "encoding" |
| 21 | + "errors" |
| 22 | + "fmt" |
| 23 | + "io" |
| 24 | + "net/http" |
| 25 | + "os" |
| 26 | + "strings" |
| 27 | + |
| 28 | + "github.com/google/go-github/github" |
| 29 | + "go.uber.org/zap" |
| 30 | + "golang.org/x/oauth2" |
| 31 | + |
| 32 | + "go.opentelemetry.io/build-tools/issuegenerator/internal/report" |
| 33 | +) |
| 34 | + |
| 35 | +const ( |
| 36 | + // Keys of required environment variables |
| 37 | + githubOwnerAndRepository = "GITHUB_REPOSITORY" |
| 38 | + githubWorkflow = "GITHUB_ACTION" |
| 39 | + githubAPITokenKey = "GITHUB_TOKEN" // #nosec G101 |
| 40 | + githubSHAKey = "GITHUB_SHA" |
| 41 | + githubRefKey = "GITHUB_REF" |
| 42 | + |
| 43 | + githubOwner = "githubOwner" |
| 44 | + githubRepository = "githubRepository" |
| 45 | + |
| 46 | + // Variables used to build workflow URL. |
| 47 | + githubServerURL = "GITHUB_SERVER_URL" |
| 48 | + githubRunID = "GITHUB_RUN_ID" |
| 49 | + autoGeneratedLabel = "generated-by-issuegenerator" |
| 50 | + issueTitleTemplate = `[${module}]: Report for failed tests on main` |
| 51 | + issueBodyTemplate = ` |
| 52 | +Auto-generated report for ${jobName} job build. |
| 53 | +
|
| 54 | +Link to failed build: ${linkToBuild} |
| 55 | +Commit: ${commit} |
| 56 | +PR: ${pr} |
| 57 | +
|
| 58 | +### Component(s) |
| 59 | +${component} |
| 60 | +
|
| 61 | +${failedTests} |
| 62 | +
|
| 63 | +**Note**: Information about any subsequent build failures that happen while |
| 64 | +this issue is open, will be added as comments with more information to this issue. |
| 65 | +` |
| 66 | + issueCommentTemplate = ` |
| 67 | +Link to latest failed build: ${linkToBuild} |
| 68 | +Commit: ${commit} |
| 69 | +PR: ${pr} |
| 70 | +
|
| 71 | +${failedTests} |
| 72 | +` |
| 73 | +) |
| 74 | + |
| 75 | +// CommaSeparatedList is a custom type for parsing comma-separated values. |
| 76 | +type CommaSeparatedList []string |
| 77 | + |
| 78 | +var _ encoding.TextMarshaler = (*CommaSeparatedList)(nil) |
| 79 | + |
| 80 | +// MarshalText is needed for flag.TextVar support. |
| 81 | +func (c CommaSeparatedList) MarshalText() ([]byte, error) { |
| 82 | + return []byte(strings.Join(c, ",")), nil |
| 83 | +} |
| 84 | + |
| 85 | +var _ encoding.TextUnmarshaler = (*CommaSeparatedList)(nil) |
| 86 | + |
| 87 | +// UnmarshalText is needed for flag.TextVar support. |
| 88 | +func (c *CommaSeparatedList) UnmarshalText(text []byte) error { |
| 89 | + for _, key := range strings.Split(string(text), ",") { |
| 90 | + key = strings.TrimSpace(key) |
| 91 | + if key == "" { |
| 92 | + return errors.New("empty key in comma-separated list") |
| 93 | + } |
| 94 | + *c = append(*c, key) |
| 95 | + } |
| 96 | + return nil |
| 97 | +} |
| 98 | + |
| 99 | +// ClientConfig includes all the configuration to create a [Client]. |
| 100 | +type ClientConfig struct { |
| 101 | + Labels CommaSeparatedList |
| 102 | +} |
| 103 | + |
| 104 | +func (c *ClientConfig) labelsCopy() []string { |
| 105 | + newSlice := make([]string, len(c.Labels)) |
| 106 | + copy(newSlice, c.Labels) |
| 107 | + return newSlice |
| 108 | +} |
| 109 | + |
| 110 | +// Client for Github interaction |
| 111 | +type Client struct { |
| 112 | + logger *zap.Logger |
| 113 | + client *github.Client |
| 114 | + envVariables map[string]string |
| 115 | + cfg ClientConfig |
| 116 | +} |
| 117 | + |
| 118 | +// NewClient creates a new client. |
| 119 | +func NewClient(ctx context.Context, logger *zap.Logger, cfg ClientConfig) (*Client, error) { |
| 120 | + env, err := getRequiredEnv() |
| 121 | + if err != nil { |
| 122 | + return nil, err |
| 123 | + } |
| 124 | + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: env[githubAPITokenKey]}) |
| 125 | + tc := oauth2.NewClient(ctx, ts) |
| 126 | + cfg.Labels = append(cfg.Labels, autoGeneratedLabel) |
| 127 | + return &Client{ |
| 128 | + logger: logger, |
| 129 | + client: github.NewClient(tc), |
| 130 | + envVariables: env, |
| 131 | + cfg: cfg, |
| 132 | + }, nil |
| 133 | +} |
| 134 | + |
| 135 | +// getRequiredEnv loads required environment variables for the main method. |
| 136 | +// Some of the environment variables are built-in in Github Actions, whereas others |
| 137 | +// need to be configured. See https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables |
| 138 | +// for a list of built-in environment variables. |
| 139 | +func getRequiredEnv() (map[string]string, error) { |
| 140 | + env := map[string]string{} |
| 141 | + |
| 142 | + // As shown in the docs, the GITHUB_REPOSITORY environment variable is of the form |
| 143 | + // owner/repository. |
| 144 | + // See https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#default-environment-variables:~:text=or%20tag.-,GITHUB_REPOSITORY,-The%20owner%20and |
| 145 | + ownerAndRepository := strings.Split(os.Getenv(githubOwnerAndRepository), "/") |
| 146 | + env[githubOwner] = ownerAndRepository[0] |
| 147 | + env[githubRepository] = ownerAndRepository[1] |
| 148 | + env[githubWorkflow] = os.Getenv(githubWorkflow) |
| 149 | + env[githubServerURL] = os.Getenv(githubServerURL) |
| 150 | + env[githubRunID] = os.Getenv(githubRunID) |
| 151 | + env[githubAPITokenKey] = os.Getenv(githubAPITokenKey) |
| 152 | + env[githubSHAKey] = os.Getenv(githubSHAKey) |
| 153 | + env[githubRefKey] = os.Getenv(githubRefKey) |
| 154 | + |
| 155 | + for k, v := range env { |
| 156 | + if v == "" { |
| 157 | + return nil, fmt.Errorf("required environment variable %q not set", k) |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + return env, nil |
| 162 | +} |
| 163 | + |
| 164 | +// GetExistingIssue gathers an existing GitHub Issue related to previous failures |
| 165 | +// of the same module. |
| 166 | +func (c *Client) GetExistingIssue(ctx context.Context, module string) *github.Issue { |
| 167 | + |
| 168 | + componentName := getComponent(trimModule(c.envVariables[githubOwner], c.envVariables[githubRepository], module)) |
| 169 | + issues, response, err := c.client.Issues.ListByRepo( |
| 170 | + ctx, |
| 171 | + c.envVariables[githubOwner], |
| 172 | + c.envVariables[githubRepository], |
| 173 | + &github.IssueListByRepoOptions{ |
| 174 | + State: "open", |
| 175 | + Labels: []string{autoGeneratedLabel, componentName}, |
| 176 | + }, |
| 177 | + ) |
| 178 | + if err != nil { |
| 179 | + c.logger.Fatal("Failed to search GitHub Issues", zap.Error(err)) |
| 180 | + } |
| 181 | + |
| 182 | + if response.StatusCode != http.StatusOK { |
| 183 | + c.handleBadResponses(response) |
| 184 | + } |
| 185 | + |
| 186 | + if len(issues) > 0 { |
| 187 | + if len(issues) > 1 { |
| 188 | + issueLinks := make([]string, len(issues)) |
| 189 | + for i, issue := range issues { |
| 190 | + if issue.HTMLURL != nil { |
| 191 | + issueLinks[i] = *issue.HTMLURL |
| 192 | + } |
| 193 | + c.logger.Warn( |
| 194 | + "Multiple existing Issues found for the same component", |
| 195 | + zap.Strings("issue_links", issueLinks), |
| 196 | + ) |
| 197 | + } |
| 198 | + } |
| 199 | + return issues[0] |
| 200 | + } |
| 201 | + return nil |
| 202 | +} |
| 203 | + |
| 204 | +// CommentOnIssue adds a new comment on an existing GitHub issue with |
| 205 | +// information about the latest failure. This method is expected to be |
| 206 | +// called only if there's an existing open Issue for the current job. |
| 207 | +func (c *Client) CommentOnIssue(ctx context.Context, r report.Report, issue *github.Issue) *github.IssueComment { |
| 208 | + body := os.Expand(issueCommentTemplate, templateHelper(c.envVariables, r)) |
| 209 | + |
| 210 | + issueComment, response, err := c.client.Issues.CreateComment( |
| 211 | + ctx, |
| 212 | + c.envVariables[githubOwner], |
| 213 | + c.envVariables[githubRepository], |
| 214 | + *issue.Number, |
| 215 | + &github.IssueComment{ |
| 216 | + Body: &body, |
| 217 | + }, |
| 218 | + ) |
| 219 | + if err != nil { |
| 220 | + c.logger.Fatal("Failed to search GitHub Issues", zap.Error(err)) |
| 221 | + } |
| 222 | + |
| 223 | + if response.StatusCode != http.StatusCreated { |
| 224 | + c.handleBadResponses(response) |
| 225 | + } |
| 226 | + |
| 227 | + return issueComment |
| 228 | +} |
| 229 | + |
| 230 | +func trimModule(owner, repo, module string) string { |
| 231 | + return strings.TrimPrefix(module, fmt.Sprintf("github.com/%s/%s/", owner, repo)) |
| 232 | +} |
| 233 | + |
| 234 | +func getComponent(module string) string { |
| 235 | + parts := strings.Split(module, "/") |
| 236 | + if len(parts) >= 2 { |
| 237 | + return strings.Join(parts[:2], "/") |
| 238 | + } |
| 239 | + return module |
| 240 | +} |
| 241 | + |
| 242 | +func templateHelper(env map[string]string, r report.Report) func(string) string { |
| 243 | + return func(param string) string { |
| 244 | + switch param { |
| 245 | + case "jobName": |
| 246 | + return "`" + env[githubWorkflow] + "`" |
| 247 | + case "linkToBuild": |
| 248 | + return fmt.Sprintf("%s/%s/%s/actions/runs/%s", env[githubServerURL], env[githubOwner], env[githubRepository], env[githubRunID]) |
| 249 | + case "failedTests": |
| 250 | + return r.FailedTestsMD() |
| 251 | + case "component": |
| 252 | + trimmedModule := trimModule(env[githubOwner], env[githubRepository], r.Module) |
| 253 | + return getComponent(trimmedModule) |
| 254 | + case "commit": |
| 255 | + return env[githubSHAKey] |
| 256 | + case "pr": |
| 257 | + ref := env[githubRefKey] |
| 258 | + parts := strings.Split(ref, "/") |
| 259 | + if len(parts) == 4 && parts[1] == "pull" { |
| 260 | + return "#" + parts[2] |
| 261 | + } |
| 262 | + return fmt.Sprintf("N/A (github_ref: %q)", ref) |
| 263 | + default: |
| 264 | + return "" |
| 265 | + } |
| 266 | + } |
| 267 | +} |
| 268 | + |
| 269 | +// CreateIssue creates a new GitHub Issue corresponding to a build failure. |
| 270 | +func (c *Client) CreateIssue(ctx context.Context, r report.Report) *github.Issue { |
| 271 | + trimmedModule := trimModule(c.envVariables[githubOwner], c.envVariables[githubRepository], r.Module) |
| 272 | + title := strings.Replace(issueTitleTemplate, "${module}", trimmedModule, 1) |
| 273 | + body := os.Expand(issueBodyTemplate, templateHelper(c.envVariables, r)) |
| 274 | + componentName := getComponent(trimmedModule) |
| 275 | + |
| 276 | + issueLabels := c.cfg.labelsCopy() |
| 277 | + issueLabels = append(issueLabels, componentName) |
| 278 | + |
| 279 | + issue, response, err := c.client.Issues.Create( |
| 280 | + ctx, |
| 281 | + c.envVariables[githubOwner], |
| 282 | + c.envVariables[githubRepository], |
| 283 | + &github.IssueRequest{ |
| 284 | + Title: &title, |
| 285 | + Body: &body, |
| 286 | + Labels: &issueLabels, |
| 287 | + }) |
| 288 | + if err != nil { |
| 289 | + c.logger.Fatal("Failed to create GitHub Issue", zap.Error(err)) |
| 290 | + } |
| 291 | + |
| 292 | + if response.StatusCode != http.StatusCreated { |
| 293 | + c.handleBadResponses(response) |
| 294 | + } |
| 295 | + |
| 296 | + return issue |
| 297 | +} |
| 298 | + |
| 299 | +func (c *Client) handleBadResponses(response *github.Response) { |
| 300 | + body, _ := io.ReadAll(response.Body) |
| 301 | + c.logger.Fatal( |
| 302 | + "Unexpected response from GitHub", |
| 303 | + zap.Int("status_code", response.StatusCode), |
| 304 | + zap.String("response", string(body)), |
| 305 | + zap.String("url", response.Request.URL.String()), |
| 306 | + ) |
| 307 | +} |
0 commit comments