internal/changelog/builder.go (234 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.
package changelog
import (
"context"
"errors"
"fmt"
"log"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/elastic/elastic-agent-changelog-tool/internal/changelog/fragment"
"github.com/elastic/elastic-agent-changelog-tool/internal/github"
"github.com/spf13/afero"
"gopkg.in/yaml.v3"
)
type Builder struct {
changelog Changelog
filename string
fs afero.Fs
// src is the source location to gather changelog fragments
src string
// dest is the destination location where the changelog is written to
dest string
}
func NewBuilder(fs afero.Fs, filename, version, src, dest string) *Builder {
return &Builder{
changelog: Changelog{Version: version},
filename: filename,
fs: fs,
src: src,
dest: dest,
}
}
var changelogFilePerm = os.FileMode(0660)
var errNoFragments = errors.New("no fragments found in the source folder")
func (b Builder) Build(owner, repo string) error {
log.Printf("building changelog for version: %s\n", b.changelog.Version)
log.Printf("collecting fragments from %s\n", b.src)
var files []string
err := afero.Walk(b.fs, b.src, func(path string, info os.FileInfo, err error) error {
if info.IsDir() && info.Name() == "fixtures" {
return filepath.SkipDir
}
return collectFragment(b.fs, path, info, err, &files)
})
if err != nil {
return fmt.Errorf("cannot walk path %s: %w", b.src, err)
}
if len(files) == 0 {
return errNoFragments
}
for _, file := range files {
log.Printf("parsing %s", file)
f, err := fragment.Load(b.fs, file)
if err != nil {
return fmt.Errorf("cannot load fragment from file %s: %w", file, err)
}
b.changelog.Entries = append(b.changelog.Entries, EntryFromFragment(f))
}
hc, err := github.GetHTTPClient(b.fs)
if err != nil {
return fmt.Errorf("cannot initialize http client: %w", err)
}
c := github.NewClient(hc)
graphqlClient := github.NewGraphQLClient(hc)
log.Println("Verifying entries:")
for i, entry := range b.changelog.Entries {
// Filling empty PR fields
if len(entry.LinkedPR) == 0 {
commitHash, err := GetLatestCommitHash(entry.File.Name)
if err != nil {
log.Printf("%s: cannot find commit hash, fill the PR field in changelog", entry.File.Name)
continue
}
prIDs, err := FillEmptyPRField(commitHash, owner, repo, c)
if err != nil {
log.Printf("%s: fill the PR field in changelog", entry.File.Name)
continue
}
if len(prIDs) > 1 {
log.Printf("%s: multiple PRs found, please remove all but one of them", entry.File.Name)
}
b.changelog.Entries[i].LinkedPR = prIDs
} else {
// Applying heuristics to PR fields
owner, repo, err := ExtractOwnerRepo(entry.LinkedPR[0])
if err != nil {
log.Printf("%s: check if the PR field is correct in changelog: %s", entry.File.Name, err.Error())
continue
}
originalPR, err := FindOriginalPR(entry.LinkedPR[0], owner, repo, c)
if err != nil {
log.Printf("%s: check if the PR field is correct in changelog: %s", entry.File.Name, err.Error())
continue
}
b.changelog.Entries[i].LinkedPR = []string{originalPR}
}
if len(entry.LinkedIssue) == 0 && len(b.changelog.Entries[i].LinkedPR) > 0 {
linkedIssues := []string{}
for _, prURL := range b.changelog.Entries[i].LinkedPR {
owner, repo, err := ExtractOwnerRepo(prURL)
if err != nil {
log.Printf("%s: check if the PR field is correct in changelog: %s", entry.File.Name, err.Error())
continue
}
tempIssues, err := FindIssues(graphqlClient, context.Background(), owner, repo, prURL, 50)
if err != nil {
log.Printf("%s: could not find linked issues for pr: %s: %s", entry.File.Name, entry.LinkedPR, err.Error())
continue
}
linkedIssues = append(linkedIssues, tempIssues...)
if len(linkedIssues) > 1 {
log.Printf("%s: multiple issues found, please remove all but one of them", entry.File.Name)
}
}
b.changelog.Entries[i].LinkedIssue = linkedIssues
} else if len(entry.LinkedIssue) == 1 {
_, err = ExtractEventNumber("issue", entry.LinkedIssue[0])
if err != nil {
log.Printf("%s: check if the issue field is correct in changelog: %s", entry.File.Name, err.Error())
}
}
}
data, err := yaml.Marshal(&b.changelog)
if err != nil {
return fmt.Errorf("cannot marshall changelog: %w", err)
}
outFile := path.Join(b.dest, b.filename)
log.Printf("saving changelog in: %s\n", outFile)
return afero.WriteFile(b.fs, outFile, data, changelogFilePerm)
}
func collectFragment(fs afero.Fs, path string, info os.FileInfo, err error, files *[]string) error {
if info, err := fs.Stat(path); err == nil && !info.IsDir() {
if filepath.Ext(path) == ".yaml" {
*files = append(*files, path)
} else {
log.Printf("skipping %s (not a YAML file)", path)
}
} else {
return err
}
return nil
}
func ExtractOwnerRepo(eventURL string) (string, string, error) {
urlParsed, err := url.Parse(eventURL)
if err != nil {
return "", "", fmt.Errorf("invalid url: %w", err)
}
urlParts := strings.Split(urlParsed.Path, "/")
if len(urlParts) < 1 {
return "", "", fmt.Errorf("can't get owner or repo")
}
if len(urlParts) < 3 {
return "", "", fmt.Errorf("parsed url (%s) does not have required parts", eventURL)
}
return urlParts[1], urlParts[2], nil
}
func ExtractEventNumber(linkType, eventURL string) (string, error) {
urlParts := strings.Split(eventURL, "/")
if len(urlParts) < 1 {
return "", fmt.Errorf("can't get event number")
}
switch linkType {
// maybe use regex to validate instead of a simple string check
case "pr":
if !strings.Contains(eventURL, "pull") {
return "", fmt.Errorf("link is invalid for pr")
}
case "issue":
if !strings.Contains(eventURL, "issues") {
return "", fmt.Errorf("link is invalid for issue")
}
}
return urlParts[len(urlParts)-1], nil
}
func CreateEventLink(linkType, owner, repo, eventID string) string {
switch linkType {
case "issue":
return fmt.Sprintf("https://github.com/%s/%s/issues/%s", owner, repo, eventID)
case "pr":
return fmt.Sprintf("https://github.com/%s/%s/pull/%s", owner, repo, eventID)
default:
panic("wrong linkType")
}
}
func GetLatestCommitHash(fileName string) (string, error) {
response, err := exec.Command("git", "log", "--diff-filter=A", "--format=%H", "changelog/fragments/"+fileName).Output()
if err != nil {
return "", err
}
return strings.ReplaceAll(string(response), "\n", ""), nil
}
func FindIssues(graphqlClient *github.ClientGraphQL, ctx context.Context, owner, name string, prURL string, issuesLen int) ([]string, error) {
prID, err := ExtractEventNumber("pr", prURL)
if err != nil {
return nil, err
}
prIDInt, _ := strconv.Atoi(prID)
issues, err := graphqlClient.PR.FindIssues(ctx, owner, name, prIDInt, issuesLen)
if err != nil {
return nil, err
}
issueLinks := make([]string, len(issues))
for i, issue := range issues {
issueLinks[i] = CreateEventLink("issue", owner, name, issue)
}
return issueLinks, nil
}
func FillEmptyPRField(commitHash, owner, repo string, c *github.Client) ([]string, error) {
pr, err := github.FindPR(context.Background(), c, owner, repo, commitHash)
if err != nil {
return []string{}, err
}
prLinks := []string{}
for _, item := range pr.Items {
prLinks = append(prLinks, CreateEventLink("pr", owner, repo, strconv.Itoa(item.PullRequestID)))
}
return prLinks, nil
}
func FindOriginalPR(prURL string, owner, repo string, c *github.Client) (string, error) {
linkedPR, err := ExtractEventNumber("pr", prURL)
if err != nil {
return "", err
}
linkedPRString, _ := strconv.Atoi(linkedPR)
pr, _, err := c.PullRequests.Get(context.Background(), owner, repo, linkedPRString)
if err != nil {
return "", err
}
prID, err := github.TestStrategies(pr, &github.BackportPRNumber{}, &github.PRNumber{})
if err != nil {
return "", err
}
prLink := CreateEventLink("pr", owner, repo, strconv.Itoa(prID))
return prLink, nil
}