Skip to content

Commit 4db05eb

Browse files
authored
[issuegenerator] Refactor and divide into packages (#1237)
* [issuegenerator] Refactor and divide into packages * Fix documentation * Package: * Formatting * Add autogenerated label
1 parent 954c244 commit 4db05eb

File tree

6 files changed

+628
-484
lines changed

6 files changed

+628
-484
lines changed
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
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

Comments
 (0)