Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add info from Github pull requests and security advisories #45

Merged
merged 1 commit into from
Jan 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 170 additions & 30 deletions github.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,18 @@ import (
var prr = regexp.MustCompile(`^Merge pull request(?: #([0-9]+))? from (\S+)$`)

type githubChangeProcessor struct {
repo string
cache Cache // Need a way to expire or bypass cache
repo string
linkName string
cache Cache
refreshCache bool
}

func githubChange(repo string, cache Cache) changeProcessor {
func githubChange(repo, linkName string, cache Cache, refreshCache bool) changeProcessor {
return &githubChangeProcessor{
repo: repo,
cache: cache,
repo: repo,
linkName: linkName,
cache: cache,
refreshCache: refreshCache,
}
}

Expand All @@ -50,17 +54,20 @@ func (p *githubChangeProcessor) process(c *change) error {
return err
}

title, err := getPRTitle(p.repo, pr, p.cache)
info, err := p.getPRInfo(p.repo, pr)
if err != nil {
return err
}
p.prChange(c, info, pr)

c.Title = title
c.Link = fmt.Sprintf("https://github.com/%s/pull/%d", p.repo, pr)
c.Formatted = fmt.Sprintf("%s ([#%d](%s))", c.Title, pr, c.Link)
} else if strings.HasPrefix(string(matches[2]), "GHSA-") {
c.Link = fmt.Sprintf("https://github.com/%s/security/advisories/%s", p.repo, matches[2])
c.Formatted = fmt.Sprintf("Github Security Advisory [%s](%s)", matches[2], c.Link)
ghsa := string(matches[2])
info, err := p.getAdvisoryInfo(p.repo, ghsa)
if err != nil {
return err
}
p.advisoryChange(c, info, ghsa)

} else {
logrus.Debugf("Nothing matched: %q", c.Description)
}
Expand All @@ -83,48 +90,181 @@ func (p *githubChangeProcessor) process(c *change) error {
return nil
}

// getPRTitle returns the Pull Request title from the github API
// TODO: Update to also return labels
func getPRTitle(repo string, prn int64, cache Cache) (string, error) {
func (p *githubChangeProcessor) prChange(c *change, info pullRequestInfo, pr int64) {
for _, l := range info.Labels {
if l.Name == "impact/changelog" {
c.IsHighlight = true
} else if l.Name == "impact/breaking" {
c.IsBreaking = true
} else if l.Name == "impact/deprecation" {
c.IsDeprecation = true
} else if strings.HasPrefix(l.Name, "area/") {
if l.Description != "" {
c.Category = l.Description
} else {
c.Category = l.Name[5:]
}
}
}
c.Title = info.Title
if len(c.Title) > 0 && c.Title[0] == '[' {
idx := strings.IndexByte(c.Title, ']')
if idx > 0 {
c.Title = strings.TrimSpace(c.Title[idx:])
}
}

if c.Link == "" {
c.Link = fmt.Sprintf("https://github.com/%s/pull/%d", p.repo, pr)
}
c.Formatted = fmt.Sprintf("%s ([%s#%d](%s))", c.Title, p.linkName, pr, c.Link)
}

type pullRequestLabel struct {
Name string `json:"name"`
Description string `json:"description"`
}

type pullRequestInfo struct {
Title string `json:"title"`
Labels []pullRequestLabel `json:"labels"`
}

// getPRInfo returns the Pull Request info from the github API
//
// See https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#get-a-pull-request
func (p *githubChangeProcessor) getPRInfo(repo string, prn int64) (pullRequestInfo, error) {
u := fmt.Sprintf("https://api.github.com/repos/%s/pulls/%d", repo, prn)
key := u + " title"
if b, ok := cache.Get(key); ok { // TODO: Provide option to refresh cache
return string(b), nil
key := u + " title labels"
if !p.refreshCache {
if b, ok := p.cache.Get(key); ok {
var info pullRequestInfo
if err := json.Unmarshal(b, &info); err == nil {
return info, nil
}
}
}
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return "", err
return pullRequestInfo{}, err
}
req.Header.Add("Accept", "application/vnd.github.v3+json")
req.Header.Add("Accept", "application/vnd.github+json")
req.Header.Add("X-GitHub-Api-Version", "2022-11-28")
if user, token := os.Getenv("GITHUB_ACTOR"), os.Getenv("GITHUB_TOKEN"); user != "" && token != "" {
req.SetBasicAuth(user, token)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
return pullRequestInfo{}, err
}
defer resp.Body.Close()

if resp.StatusCode >= 400 {
if resp.StatusCode >= 403 {
logrus.Warn("Forbidden response, try setting GITHUB_USER and GITHUB_TOKEN environment variables")
}
return "", fmt.Errorf("unexpected status code %d for %s", resp.StatusCode, u)
return pullRequestInfo{}, fmt.Errorf("unexpected status code %d for %s", resp.StatusCode, u)
}

dec := json.NewDecoder(resp.Body)

pr := struct {
Title string `json:"title"`
}{}
if err := dec.Decode(&pr); err != nil {
return "", err
var info pullRequestInfo
if err := dec.Decode(&info); err != nil {
return pullRequestInfo{}, err
}
if info.Title == "" {
return pullRequestInfo{}, fmt.Errorf("unexpected empty title for %s", u)
}

cacheB, err := json.Marshal(info)
if err == nil {
p.cache.Put(key, cacheB)
}

return info, nil
}

func (p *githubChangeProcessor) advisoryChange(c *change, info advisoryInfo, ghsa string) {
c.IsSecurity = true
c.Link = info.Link
if c.Link == "" {
c.Link = fmt.Sprintf("https://github.com/%s/security/advisories/%s", p.repo, ghsa)
}
summary := info.Summary
if summary == "" {
summary = "Github Security Advisory"
}
c.Formatted = fmt.Sprintf("%s [%s](%s)", summary, ghsa, c.Link)
cveInfo := []string{}
if info.CVE != "" {
cveInfo = append(cveInfo, info.CVE)
}
if info.Severity != "" {
cveInfo = append(cveInfo, info.Severity)
}
if len(cveInfo) > 0 {
prefix := "[" + strings.Join(cveInfo, ", ") + "] "
c.Formatted = prefix + c.Formatted
}
if pr.Title == "" {
return "", fmt.Errorf("unexpected empty title for %s", u)
}

type advisoryInfo struct {
CVE string `json:"cve_id"`
Link string `json:"html_url"`
Summary string `json:"summary"`
Description string `json:"description"`
Severity string `json:"severity"`
}

// getAdvisoryInfo returns github security advisory info
//
// See https://docs.github.com/en/rest/security-advisories/repository-advisories?apiVersion=2022-11-28#get-a-repository-security-advisory
func (p *githubChangeProcessor) getAdvisoryInfo(repo, advisory string) (advisoryInfo, error) {
u := fmt.Sprintf("https://api.github.com/repos/%s/security-advisories/%s", repo, advisory)
key := u + " cve link summary description severity"
if !p.refreshCache {
if b, ok := p.cache.Get(key); ok {
var info advisoryInfo
if err := json.Unmarshal(b, &info); err == nil {
return info, nil
}
}
}
req, err := http.NewRequest("GET", u, nil)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor and probably a follow-on cleanup, but this is the 2nd instance of setting up an HTTP API call to GH with API version, auth, error handling; might be worth extracting to a function so if the headers/API version needs an update in the future it's easy to do in one place.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah we can, originally they were slightly different because the API docs seemed to be updated since I started working on it. Good reason to have it once.

if err != nil {
return advisoryInfo{}, err
}
req.Header.Add("Accept", "application/vnd.github+json")
req.Header.Add("X-GitHub-Api-Version", "2022-11-28")
if user, token := os.Getenv("GITHUB_ACTOR"), os.Getenv("GITHUB_TOKEN"); user != "" && token != "" {
req.SetBasicAuth(user, token)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return advisoryInfo{}, err
}
defer resp.Body.Close()

if resp.StatusCode >= 400 {
if resp.StatusCode >= 403 {
logrus.Warn("Forbidden response, try setting GITHUB_USER and GITHUB_TOKEN environment variables")
}
return advisoryInfo{}, fmt.Errorf("unexpected status code %d for %s", resp.StatusCode, u)
}

dec := json.NewDecoder(resp.Body)

var info advisoryInfo
if err := dec.Decode(&info); err != nil {
return advisoryInfo{}, err
}

cacheB, err := json.Marshal(info)
if err == nil {
p.cache.Put(key, cacheB)
}

cache.Put(key, []byte(pr.Title))
return pr.Title, nil
return info, nil
}
55 changes: 42 additions & 13 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ type change struct {
Category string
Link string

IsMerge bool
//IsBreaking bool
//IsHighlight bool
IsMerge bool
IsHighlight bool
IsBreaking bool
IsDeprecation bool
IsSecurity bool

Formatted string
}
Expand Down Expand Up @@ -90,6 +92,16 @@ type contributor struct {
OtherNames []string
}

type highlightChange struct {
Project string
Change *change
}

type highlightCategory struct {
Name string
Changes []highlightChange
}

type release struct {
ProjectName string `toml:"project_name"`
GithubRepo string `toml:"github_repo"`
Expand Down Expand Up @@ -120,6 +132,7 @@ type release struct {

// generated fields
Changes []projectChange
Highlights []highlightCategory
Contributors []contributor
Dependencies []dependency
Tag string
Expand Down Expand Up @@ -160,6 +173,11 @@ This tool should be ran from the root of the project repository for a new releas
Aliases: []string{"l"},
Usage: "add links to changelog",
},
&cli.BoolFlag{
Name: "highlights",
Aliases: []string{"g"},
Usage: "use highlights based on pull request",
},
&cli.BoolFlag{
Name: "short",
Aliases: []string{"s"},
Expand All @@ -174,14 +192,21 @@ This tool should be ran from the root of the project repository for a new releas
Usage: "cache directory for static remote resources",
EnvVars: []string{"RELEASE_TOOL_CACHE"},
},
&cli.BoolFlag{
Name: "refresh-cache",
Aliases: []string{"r"},
Usage: "refreshes cache",
},
}
app.Action = func(context *cli.Context) error {
var (
releasePath = context.Args().First()
tag = context.String("tag")
linkify = context.Bool("linkify")
short = context.Bool("short")
skipCommits = context.Bool("skip-commits")
releasePath = context.Args().First()
tag = context.String("tag")
linkify = context.Bool("linkify")
highlights = context.Bool("highlights")
short = context.Bool("short")
skipCommits = context.Bool("skip-commits")
refreshCache = context.Bool("refresh-cache")
)
if tag == "" {
tag = parseTag(releasePath)
Expand Down Expand Up @@ -237,9 +262,9 @@ This tool should be ran from the root of the project repository for a new releas
if err != nil {
return err
}
if linkify {
if linkify || highlights {
for _, change := range changes {
if err := githubChange(r.GithubRepo, cache).process(change); err != nil {
if err := githubChange(r.GithubRepo, "", cache, refreshCache).process(change); err != nil {
return err
}
if !change.IsMerge {
Expand Down Expand Up @@ -350,13 +375,13 @@ This tool should be ran from the root of the project repository for a new releas
if err := addContributors(dep.Previous, dep.Ref, contributors); err != nil {
return fmt.Errorf("failed to get authors for %s: %w", name, err)
}
if linkify {
if linkify || highlights {
if !strings.HasPrefix(dep.Name, "github.com/") {
logrus.Debugf("linkify only supported for Github, skipping %s", dep.Name)
} else {
ghname := dep.Name[11:]
for _, change := range changes {
if err := githubChange(ghname, cache).process(change); err != nil {
if err := githubChange(ghname, ghname, cache, refreshCache).process(change); err != nil {
return err
}
if !change.IsMerge {
Expand Down Expand Up @@ -388,7 +413,11 @@ This tool should be ran from the root of the project repository for a new releas
// update the release fields with generated data
r.Contributors = orderContributors(contributors)
r.Dependencies = updatedDeps
r.Changes = projectChanges
if highlights {
r.Highlights = groupHighlights(projectChanges)
} else {
r.Changes = projectChanges
}
r.Tag = tag
r.Version = version

Expand Down
15 changes: 15 additions & 0 deletions template.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ Welcome to the {{.Tag}} release of {{.ProjectName}}!

{{.Preface}}

{{- if .Highlights}}

### Highlights
{{- range $highlight := .Highlights}}

{{- if $highlight.Name}}

#### {{$highlight.Name}}
{{- end}}
{{ range $change := $highlight.Changes}}
* {{ $change.Change.Formatted }}
{{- end}}
{{- end}}
{{- end}}

Please try out the release binaries and report any issues at
https://github.com/{{.GithubRepo}}/issues.

Expand Down
Loading