blob: db675bcc125b8b98607c612479dedcb54e2d12d6 [file] [log] [blame]
// Copyright 2023 Google LLC
//
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package relnotes
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/vfs"
)
type AggregatorImpl struct {
}
const whitespaceChars = "\n\r\t "
var milestoneRegex = regexp.MustCompile(`^Milestone (?P<num>\d+)$`)
// isNotesFile determines if a filename represents a release notes file.
func isNotesFile(filename string) bool {
b := path.Base(filename)
return b != "README.md" && b != ".md" && filepath.Ext(b) == ".md"
}
// getMilestone will return the milestone number in a single line of
// text. If none is found -1 will be returned.
func getMilestone(line string) int {
if match := milestoneRegex.FindStringSubmatch(line); len(match) > 0 {
if val, err := strconv.Atoi(match[1]); err == nil {
return val
}
}
return -1
}
// NewAggregator creates a new relnotes AggregatorImpl object that can be used
// to aggregate release notes.
func NewAggregator() *AggregatorImpl {
return &AggregatorImpl{}
}
// provide implementation of ListNoteFiles.
func (a *AggregatorImpl) ListNoteFiles(ctx context.Context, fs vfs.FS, notesDir string) ([]string, error) {
infos, err := vfs.ReadDir(ctx, fs, notesDir)
if err != nil {
return nil, skerr.Wrapf(err, "Failure listing notes directory %q", notesDir)
}
baseNames := make([]string, 0, len(infos))
for _, info := range infos {
if info.IsDir() || !isNotesFile(info.Name()) {
continue
}
baseNames = append(baseNames, info.Name())
}
sort.Strings(baseNames)
return baseNames, nil
}
// Read all notes and return a slice containing the raw file contents. This
// slice is ordered by the filenames from which they are read.
func (a *AggregatorImpl) readAllNotes(ctx context.Context, fs vfs.FS, notesDir string) ([][]byte, error) {
baseNames, err := a.ListNoteFiles(ctx, fs, notesDir)
if err != nil {
return nil, skerr.Wrap(err)
}
fileContents := make([][]byte, 0, len(baseNames))
for _, fname := range baseNames {
// Hard code slash path separator instead of path.Join() to simplify tests.
notePath := fmt.Sprintf("%s/%s", notesDir, fname)
data, err := vfs.ReadFile(ctx, fs, notePath)
if err != nil {
return nil, skerr.Wrapf(err, "Error reading %q", notePath)
}
fileContents = append(fileContents, data)
}
return fileContents, nil
}
// writeNote will write a single raw release note, as read from
// its file, using the writer identified by |w|, as an unordered
// list entry in Markdown format.
func writeNote(noteData []byte, w io.Writer) error {
if len(noteData) == 0 {
return skerr.Fmt("No note data")
}
if _, err := fmt.Fprint(w, " * "); err != nil {
return skerr.Wrap(err)
}
firstLine := true
reader := bytes.NewReader(noteData)
scanner := bufio.NewScanner(reader)
numPendingEmptyLines := 0
for scanner.Scan() {
text := strings.TrimRight(scanner.Text(), whitespaceChars)
if firstLine {
if len(text) == 0 {
continue
}
firstLine = false
text = fmt.Sprintf("%s\n", text)
} else {
if len(text) > 0 {
text = fmt.Sprintf(" %s\n", text)
for numPendingEmptyLines > 0 {
if _, err := fmt.Fprintln(w, ""); err != nil {
return skerr.Wrap(err)
}
numPendingEmptyLines--
}
} else {
numPendingEmptyLines++
}
}
if _, err := fmt.Fprint(w, text); err != nil {
return skerr.Wrap(err)
}
}
return nil
}
func (a *AggregatorImpl) writeAllNotes(ctx context.Context, fs vfs.FS, w io.Writer, relnotesDir string) error {
allNoteData, err := a.readAllNotes(ctx, fs, relnotesDir)
if err != nil {
return skerr.Wrap(err)
}
for _, noteData := range allNoteData {
if err = writeNote(noteData, w); err != nil {
return skerr.Wrap(err)
}
}
return nil
}
// writeNewMilestoneSection will read all release notes contained in the
// |relnotesDir| directory, aggregate them under a new milestone heading, and
// write that new heading using the |w| writer.
func (a *AggregatorImpl) writeNewMilestoneSection(ctx context.Context, fs vfs.FS, w io.Writer, milestone int, relnotesDir string) error {
if _, err := fmt.Fprintf(w, "Milestone %d\n", milestone); err != nil {
return skerr.Wrap(err)
}
if _, err := fmt.Fprintln(w, "-------------"); err != nil {
return skerr.Wrap(err)
}
if err := a.writeAllNotes(ctx, fs, w, relnotesDir); err != nil {
return skerr.Wrap(err)
}
return nil
}
// Aggregate does the following:
//
// 1. Read all release notes contained within the |relnotesDir| directory.
// 2. Create a new m+1 milestone heading with the new release notes
// included in an unordered list.
// 3. Return a new byte array which are the existing release notes, read from
// |aggregateFilePath|, but with the new milestone section inserted at the
// top of the stream.
//
// This function does not modify any files on disk. The caller is responsible
// for writing these modified release notes to disk if desired.
func (a *AggregatorImpl) Aggregate(ctx context.Context, fs vfs.FS, newMilestone int, aggregateFilePath, relnotesDir string) ([]byte, error) {
b, err := vfs.ReadFile(ctx, fs, aggregateFilePath)
if err != nil {
return nil, skerr.Wrapf(err, "Unable to open current release notes")
}
r := bytes.NewReader(b)
var newContents bytes.Buffer
scanner := bufio.NewScanner(r)
gotFirstMilestoneHeading := false
prevMilestone := newMilestone - 1
insertNotesAfterNextHeadingUnderlines := false
for scanner.Scan() {
t := scanner.Text()
if !gotFirstMilestoneHeading {
if m := getMilestone(t); m != -1 {
gotFirstMilestoneHeading = true
if m == newMilestone {
// The release notes file already has a section for the new milestone.
// This should only be the case for the first milestone release after
// switching to the new release notes process with individual files.
// Insert all new notes just after the milestone heading, but don't
// create a new one.
insertNotesAfterNextHeadingUnderlines = true
} else if m == prevMilestone {
if err = a.writeNewMilestoneSection(ctx, fs, &newContents,
newMilestone, relnotesDir); err != nil {
return nil, skerr.Wrap(err)
}
fmt.Fprintf(&newContents, "\n* * *\n\n")
} else {
return nil, skerr.Fmt("Cannot jump from milestone %d to %d", m, newMilestone)
}
}
}
if _, err = fmt.Fprintln(&newContents, t); err != nil {
return nil, skerr.Wrap(err)
}
if insertNotesAfterNextHeadingUnderlines && t == "-------------" {
insertNotesAfterNextHeadingUnderlines = false
if err = a.writeAllNotes(ctx, fs, &newContents, relnotesDir); err != nil {
return nil, skerr.Wrap(err)
}
}
}
if !gotFirstMilestoneHeading {
return nil, skerr.Fmt("%s does not contain an existing milestone section",
aggregateFilePath)
}
if err := scanner.Err(); err != nil {
return nil, skerr.Wrap(err)
}
return newContents.Bytes(), nil
}
// Make sure AggregatorImpl fulfills the Aggregator interface.
var _ Aggregator = (*AggregatorImpl)(nil)