tools/go-changelog/entry.go (202 lines of code) (raw):

// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package changelog import ( "fmt" "io/ioutil" "path/filepath" "sort" "sync" "time" "golang.org/x/sync/errgroup" "github.com/go-git/go-billy/v5/memfs" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/storage/memory" ) type Entry struct { Issue string Body string Date time.Time Hash string } // EntryList provides thread-safe operations on a list of Entry values type EntryList struct { mu sync.RWMutex es []*Entry } type EntryErrorCode string const ( EntryErrorNotFound EntryErrorCode = "NOT_FOUND" EntryErrorUnknownTypes EntryErrorCode = "UNKNOWN_TYPES" EntryErrorInvalidNewReourceOrDatasourceFormat EntryErrorCode = "INVALID_NEW_RESOURCE_OR_DATASOURCE_FORMAT" EntryErrorMultipleLines EntryErrorCode = "MULTIPLE_LINES" EntryErrorInvalidEnhancementOrBugFixFormat EntryErrorCode = "INVALID_ENHANCEMENT_OR_BUGFIX_FORMAT" ) type EntryValidationError struct { message string Code EntryErrorCode Details map[string]interface{} } func (e *EntryValidationError) Error() string { return e.message } // Validates that an Entry body contains properly formatted changelog notes func (e *Entry) Validate() []*EntryValidationError { notes := NotesFromEntry(*e) var errors []*EntryValidationError if len(notes) < 1 { errors = append(errors, &EntryValidationError{ message: fmt.Sprintf("no changelog entry found in: %s", string(e.Body)), Code: EntryErrorNotFound, }) return errors } for _, note := range notes { err := note.Validate() if err != nil { errors = append(errors, err) } } return errors } // NewEntryList returns an EntryList with capacity c func NewEntryList(c int) *EntryList { return &EntryList{ es: make([]*Entry, 0, c), } } // Append appends entries to the EntryList func (el *EntryList) Append(entries ...*Entry) { el.mu.Lock() defer el.mu.Unlock() el.es = append(el.es, entries...) } // Get returns the Entry at index i func (el *EntryList) Get(i int) *Entry { el.mu.RLock() defer el.mu.RUnlock() if i >= len(el.es) || i < 0 { return nil } return el.es[i] } // Set sets the Entry at index i. The list will be resized if i is larger than // the current list capacity. func (el *EntryList) Set(i int, e *Entry) { if i < 0 { panic("invalid slice index") } el.mu.Lock() defer el.mu.Unlock() if i > (cap(el.es) - 1) { // resize the slice newEntries := make([]*Entry, i) copy(newEntries, el.es) el.es = newEntries } el.es[i] = e } // Len returns the number of items in the EntryList func (el *EntryList) Len() int { el.mu.RLock() defer el.mu.RUnlock() return len(el.es) } // SortByIssue does an in-place sort of the entries by their issue number. func (el *EntryList) SortByIssue() { el.mu.Lock() defer el.mu.Unlock() sort.Slice(el.es, func(i, j int) bool { return el.es[i].Issue < el.es[j].Issue }) } type changelog struct { content []byte hash string date time.Time } // Diff returns the slice of Entry values that represent the difference of // entries in the dir directory within repo from ref1 revision to ref2 revision. // ref1 and ref2 should be valid git refs as strings and dir should be a valid // directory path in the repository. // // The function calculates the diff by first checking out ref2 and collecting // the set of all entries in dir. It then checks out ref1 and subtracts the // entries found in dir. The resulting set of entries is then filtered to // exclude any entries that came before the commit date of ref1. // // Along the way, if any git or filesystem interactions fail, an error is returned. func Diff(repo, ref1, ref2, dir string) (*EntryList, error) { r, err := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{ URL: repo, }) if err != nil { return nil, err } rev2, err := r.ResolveRevision(plumbing.Revision(ref2)) if err != nil { return nil, fmt.Errorf("could not resolve revision %s: %w", ref2, err) } var rev1 *plumbing.Hash if ref1 != "-" { rev1, err = r.ResolveRevision(plumbing.Revision(ref1)) if err != nil { return nil, fmt.Errorf("could not resolve revision %s: %w", ref1, err) } } wt, err := r.Worktree() if err != nil { return nil, err } if err := wt.Checkout(&git.CheckoutOptions{ Hash: *rev2, Force: true, }); err != nil { return nil, fmt.Errorf("could not checkout repository at %s: %w", ref2, err) } entriesAfterFI, err := wt.Filesystem.ReadDir(dir) if err != nil { return nil, fmt.Errorf("could not read repository directory %s: %w", dir, err) } // a set of all entries at rev2 (this release); the set of entries at ref1 // will then be subtracted from it to arrive at a set of 'candidate' entries. entryCandidates := make(map[string]bool, len(entriesAfterFI)) for _, i := range entriesAfterFI { entryCandidates[i.Name()] = true } if rev1 != nil { err = wt.Checkout(&git.CheckoutOptions{ Hash: *rev1, Force: true, }) if err != nil { return nil, err } entriesBeforeFI, err := wt.Filesystem.ReadDir(dir) if err != nil { return nil, fmt.Errorf("could not read repository directory %s: %w", dir, err) } for _, i := range entriesBeforeFI { delete(entryCandidates, i.Name()) } // checkout rev2 so that we can read files later if err := wt.Checkout(&git.CheckoutOptions{ Hash: *rev2, Force: true, }); err != nil { return nil, fmt.Errorf("could not checkout repository at %s: %w", ref2, err) } } entries := NewEntryList(len(entryCandidates)) errg := new(errgroup.Group) for name := range entryCandidates { name := name // https://golang.org/doc/faq#closures_and_goroutines errg.Go(func() error { fp := filepath.Join(dir, name) f, err := wt.Filesystem.Open(fp) if err != nil { return fmt.Errorf("error opening file at %s: %w", name, err) } contents, err := ioutil.ReadAll(f) f.Close() if err != nil { return fmt.Errorf("error reading file at %s: %w", name, err) } log, err := r.Log(&git.LogOptions{FileName: &fp}) if err != nil { return fmt.Errorf("error fetching git log for %s: %w", name, err) } lastChange, err := log.Next() if err != nil { return fmt.Errorf("error fetching next git log: %w", err) } entries.Append(&Entry{ Issue: name, Body: string(contents), Date: lastChange.Author.When, Hash: lastChange.Hash.String(), }) return nil }) } if err := errg.Wait(); err != nil { return nil, err } entries.SortByIssue() return entries, nil }