blob: 6948a10e7f0a1570366f74dabab00c95acbdc3dc [file] [log] [blame]
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/*
CLI tool to generate release notes based on git logs in the current directory.
Usage examples:
go run relnotes.go -since-hash 7bb5fff0fcb57b467a8f907aeee9117e09106d06
or
go run relnotes.go -since-date 2016-02-04
*/
package main
import (
"bufio"
"context"
"flag"
"fmt"
"io/ioutil"
"os"
"os/exec"
"regexp"
"strings"
"text/template"
"time"
"golang.org/x/oauth2/google"
"gopkg.in/yaml.v2"
appengine "google.golang.org/api/appengine/v1"
"go.chromium.org/luci/common/data/stringset"
)
const monorailURL = "https://bugs.chromium.org/p/%s/issues/detail?id=%s"
var (
appName = flag.String("app", "", "Name of the application")
date = flag.String("date", "", "YYYY-MM-DD. Release date.")
sinceDate = flag.String("since-date", "", "YYYY-MM-DD. All changes since this date.")
sinceHash = flag.String("since-hash", "", "All changes since this long hash.")
bugRE = regexp.MustCompile(`\n BUG[=:][\s]*([0-9]+)`)
monorailRE = regexp.MustCompile(`\n BUG[=:][\s]*([a-z]+):([0-9]+)`)
authorRE = regexp.MustCompile("\nAuthor:.+<(.+)>")
hashRE = regexp.MustCompile("commit (.*)\n")
reviewRE = regexp.MustCompile("\n (Review-Url|Reviewed-on): (.*)\n")
extraPaths = flag.String("extra-paths", "", "Comma-separated list of extra paths to check.")
markdownTxt = `
# Release Notes {{.AppName}} {{.Date}}
- {{len .Commits}} commits, {{.NumBugs}} bugs affected since {{.Since}}
- {{len .Authors}} Authors:
{{- range .Authors}}
- {{ . }}
{{- end}}
## Changes in this release
{{range .Commits -}}
- [{{.Summary}}]({{.ReviewURL}}) ({{.Author}})
{{end}}
## Bugs updated, by author
{{range $author, $bugs := .Bugs -}}
- {{$author}}:
{{range $bug, $unused := $bugs -}}
- [{{$bug}}]({{$bug}})
{{end}}
{{end}}
`
markdownTmpl = template.Must(template.New("markdown").Parse(markdownTxt))
)
type tmplData struct {
AppName string
Date string
NumBugs int
Since string
Authors []string
Commits []*commit
Bugs map[string]stringset.Set
}
type commit struct {
hash string
Author string
committer string
Summary string
ReviewURL string
bugs []string
}
func parsecommit(s string) *commit {
c := &commit{}
bugs := bugRE.FindAllStringSubmatch(strings.ToUpper(s), -1)
for _, b := range bugs {
c.bugs = append(c.bugs, fmt.Sprintf("https://crbug.com/%s", b[1]))
}
monorailBugs := monorailRE.FindAllStringSubmatch(strings.ToUpper(s), -1)
for _, b := range monorailBugs {
c.bugs = append(c.bugs, fmt.Sprintf(monorailURL, b[1], b[2]))
}
authors := authorRE.FindAllStringSubmatch(s, -1)
for _, a := range authors {
c.Author = a[1]
}
hashes := hashRE.FindAllStringSubmatch(s, -1)
for _, h := range hashes {
c.hash = h[1]
}
c.Summary = strings.Trim(strings.Split(s, "\n")[4], " \t")
reviewURL := reviewRE.FindAllStringSubmatch(s, -1)
if len(reviewURL) > 0 && len(reviewURL[0]) > 2 {
c.ReviewURL = reviewURL[0][2]
}
if strings.Trim(c.Author, "\n\t ") == "" {
fmt.Print(s)
}
return c
}
func usage() {
fmt.Fprintf(os.Stderr, "Usage of %s <flags> [relative path]:\n", os.Args[0])
flag.PrintDefaults()
}
func gaeService() (*appengine.APIService, error) {
creds := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
if creds == "" {
fmt.Printf("Warning: you do not have the GOOGLE_APPLICATION_CREDENTIALS environment variable set. Cloud API calls may not work properly.\n")
} else {
fmt.Printf("Using GOOGLE_APPLICATION_CREDENTIALS: %s\n", creds)
}
ctx := context.Background()
client, err := google.DefaultClient(ctx, appengine.CloudPlatformScope)
if err != nil {
return nil, err
}
appengineService, err := appengine.New(client)
return appengineService, err
}
// getDeployedApp returns the hash and date string, or an error.
func getDeployedApp(service, module string) (string, string, error) {
gaeSvc, err := gaeService()
if err != nil {
return "", "", err
}
appsSvc := appengine.NewAppsService(gaeSvc)
versionsListCall := appsSvc.Services.Versions.List(*appName, "default")
versionsList, err := versionsListCall.Do()
if err != nil {
return "", "", err
}
var deployedVers *appengine.Version
// This is a heuristic to determine which version is "deployed" - use
// the latest version (by creation timestamp) that is "SERVING". More
// accurate would be to look at traffic splits and pick the one that
// has the most (or all) traffic going to it. Unfortunately the API
// doesn't appear to expose that information(!).
for _, vers := range versionsList.Versions {
if vers.ServingStatus == "SERVING" && (deployedVers == nil ||
deployedVers.CreateTime < vers.CreateTime) {
deployedVers = vers
}
}
if deployedVers == nil {
return "", "", fmt.Errorf("could not determine currently deployed version")
}
versRE := regexp.MustCompile("([0-9]+)-([0-9a-f]+)")
matches := versRE.FindAllStringSubmatch(deployedVers.Id, -1)
return matches[0][2], deployedVers.CreateTime, nil
}
func getAppNameFromYAML() (string, error) {
type appStruct struct {
Application string
}
in, err := os.Open("app.yaml")
if err != nil {
return "", err
}
b, err := ioutil.ReadAll(in)
if err != nil {
return "", err
}
app := &appStruct{}
if err := yaml.Unmarshal(b, app); err != nil {
return "", err
}
return app.Application, nil
}
func getUpdates(path string) (stringset.Set, []*commit, stringset.Set, map[string]stringset.Set) {
var cmd *exec.Cmd
switch {
case *sinceHash != "":
cmd = exec.Command("git", "log", fmt.Sprintf("%s..", *sinceHash), path)
case *sinceDate != "":
cmd = exec.Command("git", "log", "--since", *sinceDate, path)
default:
fmt.Printf("Please specify either --since-hash or --since-date\n")
os.Exit(1)
}
cmd.Stderr = os.Stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
fmt.Printf("Error getting stdout: %v", err)
os.Exit(1)
}
cmd.Start()
r := bufio.NewReader(stdout)
bytes, err := ioutil.ReadAll(r)
if err != nil {
fmt.Printf("Error reading stdout: %v", err)
os.Exit(1)
}
text := string(bytes)
re := regexp.MustCompile("(^|\n)commit ")
commitMsgs := re.Split(text, -1)[1:]
commitsByBug := map[string][]*commit{}
commitsByAuthor := map[string][]*commit{}
authors := stringset.New(5)
bugs := stringset.New(5)
bugsByAuthor := map[string]stringset.Set{}
summaries := []string{}
commits := []*commit{}
for _, cstr := range commitMsgs {
c := parsecommit(cstr)
if c.ReviewURL == "" {
continue
}
commits = append(commits, c)
summaries = append(summaries, c.Summary)
for _, b := range c.bugs {
commitsByBug[b] = append(commitsByBug[b], c)
bugs.Add(b)
if _, ok := bugsByAuthor[c.Author]; !ok {
bugsByAuthor[c.Author] = stringset.New(5)
}
bugsByAuthor[c.Author].Add(b)
}
commitsByAuthor[c.Author] = append(commitsByAuthor[c.Author], c)
authors.Add(c.Author)
}
return authors, commits, bugs, bugsByAuthor
}
func main() {
flag.Usage = usage
flag.Parse()
paths := flag.Args()
if len(paths) == 0 {
paths = []string{"."}
}
if *appName == "" {
s, err := getAppNameFromYAML()
if err != nil {
fmt.Printf("Error getting app name from app.yaml: %v", err)
os.Exit(1)
}
appName = &s
fmt.Printf("Got app name from app.yaml: %s\n", *appName)
}
if *sinceHash == "" && *sinceDate == "" {
hash, date, err := getDeployedApp(*appName, "default")
if err != nil {
fmt.Printf("Error trying to get currently deployed app hash: %v\n", err)
fmt.Printf("Please specify either --since-hash or --since-date\n")
os.Exit(1)
}
sinceHash = &hash
sinceDate = &date
}
authors, commits, bugs, bugsByAuthor := stringset.New(5), []*commit{}, stringset.New(5), map[string]stringset.Set{}
for _, path := range paths {
a, c, b, bba := getUpdates(path)
authors = authors.Union(a)
commits = append(commits, c...)
bugs = bugs.Union(b)
for author, bugs := range bba {
if _, ok := bugsByAuthor[author]; !ok {
bugsByAuthor[author] = stringset.New(5)
}
bugsByAuthor[author] = bugsByAuthor[author].Union(bugs)
}
}
// De-dupe commits and bugs, as some CLs affect multiple paths and can show
// up multiple times.
commitByURL := map[string]*commit{}
dedupedCommits := []*commit{}
for _, c := range commits {
_, ok := commitByURL[c.ReviewURL]
if !ok {
dedupedCommits = append(dedupedCommits, c)
}
commitByURL[c.ReviewURL] = c
}
commits = dedupedCommits
if *date == "" {
today := time.Now().Format("2006-01-02")
date = &today
}
data := tmplData{
AppName: *appName,
Date: *date,
NumBugs: bugs.Len(),
Since: fmt.Sprintf("%s (%s)", *sinceHash, *sinceDate),
Authors: authors.ToSlice(),
Commits: commits,
Bugs: bugsByAuthor,
}
f := bufio.NewWriter(os.Stdout)
markdownTmpl.Execute(f, data)
f.Flush()
}