| // Copyright 2022 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| // Package issues provides a general way to interact with issues, | |
| // and a client for interacting with the GitHub issues API. | |
| package issues | |
| import ( | |
| "context" | |
| "fmt" | |
| "net/url" | |
| "slices" | |
| "time" | |
| "github.com/google/go-github/v89/github" | |
| "golang.org/x/oauth2" | |
| "golang.org/x/vulndb/internal/derrors" | |
| ) | |
| // Issue represents a GitHub issue. | |
| type Issue struct { | |
| Number int | |
| Title string | |
| Body string | |
| State string | |
| Assignee string | |
| Labels []string | |
| CreatedAt time.Time | |
| } | |
| // IssuesOptions are options for Issues | |
| type IssuesOptions struct { | |
| // State filters issues based on their state. Possible values are: open, | |
| // closed, all. Default is "open". | |
| State string | |
| // Labels filters issues based on their label. | |
| Labels []string | |
| } | |
| // Client is a shallow client for a github.Client. | |
| type Client struct { | |
| GitHub *github.Client | |
| Owner string | |
| Repo string | |
| } | |
| // Config is used to initialize a new Client. | |
| type Config struct { | |
| // Owner is the owner of a GitHub repo. For example, "golang" is the owner | |
| // for github.com/golang/vulndb. | |
| Owner string | |
| // Repo is the name of a GitHub repo. For example, "vulndb" is the repo | |
| // name for github.com/golang/vulndb. | |
| Repo string | |
| // Token is access token that authorizes and authenticates | |
| // requests to the GitHub API. | |
| Token string | |
| // For testing: alternative base URL and upload URL. | |
| BaseURL string | |
| } | |
| // NewClient creates a Client that will create issues in | |
| // the a GitHub repo. | |
| func NewClient(ctx context.Context, cfg *Config) (*Client, error) { | |
| ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: cfg.Token}) | |
| tc := oauth2.NewClient(ctx, ts) | |
| opts := []github.ClientOptionsFunc{ | |
| github.WithHTTPClient(tc), | |
| } | |
| if cfg.BaseURL != "" { | |
| opts = append(opts, github.WithURLs(&cfg.BaseURL, &cfg.BaseURL)) | |
| } | |
| c, err := github.NewClient(opts...) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return &Client{ | |
| GitHub: c, | |
| Owner: cfg.Owner, | |
| Repo: cfg.Repo, | |
| }, nil | |
| } | |
| // NewTestClient creates a Client for use in tests. | |
| func NewTestClient(ctx context.Context, cfg *Config, baseURL *url.URL) *Client { | |
| cfg.BaseURL = baseURL.String() | |
| c, err := NewClient(ctx, cfg) | |
| if err != nil { | |
| panic(err) | |
| } | |
| return c | |
| } | |
| // Destination returns the URL of the Github repo. | |
| func (c *Client) Destination() string { | |
| return fmt.Sprintf("https://github.com/%s/%s", c.Owner, c.Repo) | |
| } | |
| // Reference returns the URL of the given issue. | |
| func (c *Client) Reference(num int) string { | |
| return fmt.Sprintf("%s/issues/%d", c.Destination(), num) | |
| } | |
| // IssueExists reports whether an issue with the given ID exists. | |
| func (c *Client) IssueExists(ctx context.Context, number int) (_ bool, err error) { | |
| defer derrors.Wrap(&err, "IssueExists(%d)", number) | |
| iss, _, err := c.GitHub.Issues.Get(ctx, c.Owner, c.Repo, number) | |
| if err != nil { | |
| return false, err | |
| } | |
| if iss != nil { | |
| fmt.Printf("ID = %d, Number = %d\n", iss.GetID(), iss.GetNumber()) | |
| return true, nil | |
| } | |
| return false, nil | |
| } | |
| // convertGithubIssueToIssue converts a github.Issue to an Issue. | |
| func convertGithubIssueToIssue(ghIss *github.Issue) *Issue { | |
| iss := &Issue{} | |
| if ghIss.Number != nil { | |
| iss.Number = *ghIss.Number | |
| } | |
| if ghIss.Title != nil { | |
| iss.Title = *ghIss.Title | |
| } | |
| if ghIss.Number != nil { | |
| iss.Number = *ghIss.Number | |
| } | |
| if ghIss.Body != nil { | |
| iss.Body = *ghIss.Body | |
| } | |
| if ghIss.CreatedAt != nil { | |
| iss.CreatedAt = ghIss.CreatedAt.Time | |
| } | |
| if ghIss.State != nil { | |
| iss.State = *ghIss.State | |
| } | |
| if ghIss.Assignee != nil { | |
| iss.Assignee = ghIss.Assignee.GetLogin() | |
| } | |
| if ghIss.Labels != nil { | |
| iss.Labels = make([]string, len(ghIss.Labels)) | |
| for i, label := range ghIss.Labels { | |
| iss.Labels[i] = label.GetName() | |
| } | |
| } | |
| return iss | |
| } | |
| // Issue returns the issue with the given issue number. | |
| func (c *Client) Issue(ctx context.Context, number int) (_ *Issue, err error) { | |
| defer derrors.Wrap(&err, "Issue(%d)", number) | |
| ghIss, _, err := c.GitHub.Issues.Get(ctx, c.Owner, c.Repo, number) | |
| if err != nil { | |
| return nil, err | |
| } | |
| iss := convertGithubIssueToIssue(ghIss) | |
| return iss, nil | |
| } | |
| // Issues returns all Github issues that match the filters in opts. | |
| func (c *Client) Issues(ctx context.Context, opts IssuesOptions) (_ []*Issue, err error) { | |
| defer derrors.Wrap(&err, "Issues()") | |
| clientOpts := &github.IssueListByRepoOptions{ | |
| State: opts.State, | |
| Labels: opts.Labels, | |
| ListOptions: github.ListOptions{ | |
| PerPage: 100, | |
| }, | |
| } | |
| issues := []*Issue{} | |
| page := 1 | |
| for { | |
| clientOpts.ListOptions.Page = page | |
| pageIssues, resp, err := c.GitHub.Issues.ListByRepo(ctx, c.Owner, c.Repo, clientOpts) | |
| if err != nil { | |
| return nil, err | |
| } | |
| for _, giss := range pageIssues { | |
| if giss.IsPullRequest() { | |
| continue | |
| } | |
| issues = append(issues, convertGithubIssueToIssue(giss)) | |
| } | |
| if resp.NextPage == 0 { | |
| break | |
| } | |
| page = resp.NextPage | |
| } | |
| return issues, nil | |
| } | |
| // CreateIssue creates a new issue. | |
| func (c *Client) CreateIssue(ctx context.Context, iss *Issue) (number int, err error) { | |
| defer derrors.Wrap(&err, "CreateIssue(%s)", iss.Title) | |
| req := &github.IssueRequest{ | |
| Title: &iss.Title, | |
| Body: &iss.Body, | |
| } | |
| if len(iss.Labels) > 0 { | |
| req.Labels = &iss.Labels | |
| } | |
| giss, _, err := c.GitHub.Issues.Create(ctx, c.Owner, c.Repo, req) | |
| if err != nil { | |
| return 0, err | |
| } | |
| return giss.GetNumber(), nil | |
| } | |
| func (c *Client) SetLabels(ctx context.Context, issNum int, labels []string) (err error) { | |
| defer derrors.Wrap(&err, "SetLabels(%d, %s)", issNum, labels) | |
| req := &github.IssueRequest{ | |
| Labels: &labels, | |
| } | |
| _, _, err = c.GitHub.Issues.Edit(ctx, c.Owner, c.Repo, issNum, req) | |
| if err != nil { | |
| return err | |
| } | |
| return nil | |
| } | |
| func (c *Client) AddComments(ctx context.Context, issNum int, comments []string) (err error) { | |
| defer derrors.Wrap(&err, "AddComments(%d, %s)", issNum, comments) | |
| for _, comment := range comments { | |
| req := &github.IssueComment{ | |
| Body: &comment, | |
| } | |
| _, _, err = c.GitHub.Issues.CreateComment(ctx, c.Owner, c.Repo, issNum, req) | |
| if err != nil { | |
| return err | |
| } | |
| } | |
| return nil | |
| } | |
| // NewGoID creates a Go advisory ID based on the issue number | |
| // and time of issue creation. | |
| func (iss *Issue) NewGoID() string { | |
| var year int | |
| if !iss.CreatedAt.IsZero() { | |
| year = iss.CreatedAt.Year() | |
| } | |
| return fmt.Sprintf("GO-%04d-%04d", year, iss.Number) | |
| } | |
| func (iss *Issue) HasLabel(label string) bool { | |
| return slices.Contains(iss.Labels, label) | |
| } |