diff --git a/contrib/completions/bash/oc b/contrib/completions/bash/oc index 54412b6e9e91..db4cd0a56050 100644 --- a/contrib/completions/bash/oc +++ b/contrib/completions/bash/oc @@ -4645,6 +4645,8 @@ _oc_adm_release_info() flags_with_completion=() flags_completion=() + flags+=("--changelog=") + local_nonpersistent_flags+=("--changelog=") flags+=("--changes-from=") local_nonpersistent_flags+=("--changes-from=") flags+=("--commits") diff --git a/contrib/completions/zsh/oc b/contrib/completions/zsh/oc index 3fd947366e26..8a2c3b2d434e 100644 --- a/contrib/completions/zsh/oc +++ b/contrib/completions/zsh/oc @@ -4787,6 +4787,8 @@ _oc_adm_release_info() flags_with_completion=() flags_completion=() + flags+=("--changelog=") + local_nonpersistent_flags+=("--changelog=") flags+=("--changes-from=") local_nonpersistent_flags+=("--changes-from=") flags+=("--commits") diff --git a/pkg/oc/cli/admin/release/extract.go b/pkg/oc/cli/admin/release/extract.go index 92d4b15f0669..bf725a51ff52 100644 --- a/pkg/oc/cli/admin/release/extract.go +++ b/pkg/oc/cli/admin/release/extract.go @@ -12,6 +12,7 @@ import ( "path" "path/filepath" "regexp" + "strconv" "strings" "time" @@ -240,16 +241,10 @@ func (o *ExtractOptions) extractGit(dir string) error { } alreadyExtracted[repo] = commit - u, err := sourceLocationAsURL(repo) + basePath, err := sourceLocationAsRelativePath(dir, repo) if err != nil { return err } - gitPath := u.Path - if strings.HasSuffix(gitPath, ".git") { - gitPath = strings.TrimSuffix(gitPath, ".git") - } - gitPath = path.Clean(gitPath) - basePath := filepath.Join(dir, u.Host, filepath.FromSlash(gitPath)) var git *git fi, err := os.Stat(basePath) @@ -366,3 +361,107 @@ func sourceLocationAsURL(location string) (*url.URL, error) { } return url.Parse(location) } + +func sourceLocationAsRelativePath(dir, location string) (string, error) { + u, err := sourceLocationAsURL(location) + if err != nil { + return "", err + } + gitPath := u.Path + if strings.HasSuffix(gitPath, ".git") { + gitPath = strings.TrimSuffix(gitPath, ".git") + } + gitPath = path.Clean(gitPath) + basePath := filepath.Join(dir, u.Host, filepath.FromSlash(gitPath)) + return basePath, nil +} + +type MergeCommit struct { + CommitDate time.Time + + Commit string + ParentCommits []string + + PullRequest int + Bug int + + Subject string +} + +func mergeLogForRepo(g *git, from, to string) ([]MergeCommit, error) { + if from == to { + return nil, nil + } + + rePR, err := regexp.Compile(`^Merge pull request #(\d+) from`) + if err != nil { + return nil, err + } + reBug, err := regexp.Compile(`^Bug (\d+): `) + if err != nil { + return nil, err + } + + args := []string{"log", "--merges", "--topo-order", "-z", "--pretty=format:%H %P%x1E%ct%x1E%s%x1E%b", "--reverse", fmt.Sprintf("%s..%s", from, to)} + out, err := g.exec(args...) + if err != nil { + // retry once if there's a chance we haven't fetched the latest commits + if !strings.Contains(out, "Invalid revision range") { + return nil, fmt.Errorf(out) + } + if _, err := g.exec("fetch", "--all"); err != nil { + return nil, fmt.Errorf(out) + } + out, err = g.exec(args...) + if err != nil { + return nil, fmt.Errorf(out) + } + } + + if glog.V(5) { + glog.Infof("Got commit info:\n%s", strconv.Quote(out)) + } + + var commits []MergeCommit + for _, entry := range strings.Split(out, "\x00") { + records := strings.Split(entry, "\x1e") + if len(records) != 4 { + return nil, fmt.Errorf("unexpected git log output width %d columns", len(records)) + } + unixTS, err := strconv.ParseInt(records[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("unexpected timestamp: %v", err) + } + commitValues := strings.Split(records[0], " ") + + mergeCommit := MergeCommit{ + CommitDate: time.Unix(unixTS, 0).UTC(), + Commit: commitValues[0], + ParentCommits: commitValues[1:], + } + + msg := records[3] + if m := reBug.FindStringSubmatch(msg); m != nil { + mergeCommit.Subject = msg[len(m[0]):] + mergeCommit.Bug, err = strconv.Atoi(m[1]) + if err != nil { + return nil, fmt.Errorf("could not extract bug number from %q: %v", msg, err) + } + } else { + mergeCommit.Subject = msg + } + mergeCommit.Subject = strings.TrimSpace(mergeCommit.Subject) + + mergeMsg := records[2] + if m := rePR.FindStringSubmatch(mergeMsg); m != nil { + mergeCommit.PullRequest, err = strconv.Atoi(m[1]) + if err != nil { + return nil, fmt.Errorf("could not extract PR number from %q: %v", mergeMsg, err) + } + } + + commits = append(commits, mergeCommit) + } + + return commits, nil +} diff --git a/pkg/oc/cli/admin/release/info.go b/pkg/oc/cli/admin/release/info.go index cd8cd84857cf..62e5d76d6543 100644 --- a/pkg/oc/cli/admin/release/info.go +++ b/pkg/oc/cli/admin/release/info.go @@ -14,6 +14,8 @@ import ( "text/tabwriter" "time" + "github.com/MakeNowJust/heredoc" + "github.com/blang/semver" "github.com/golang/glog" @@ -63,6 +65,7 @@ func NewInfo(f kcmdutil.Factory, parentName string, streams genericclioptions.IO flags.BoolVar(&o.ShowPullSpec, "pullspecs", o.ShowPullSpec, "Display the pull spec of each image instead of the digest.") flags.StringVar(&o.ImageFor, "image-for", o.ImageFor, "Print the pull spec of the specified image or an error if it does not exist.") flags.StringVarP(&o.Output, "output", "o", o.Output, "Display the release info in an alternative format: json") + flags.StringVar(&o.ChangelogDir, "changelog", o.ChangelogDir, "Generate changelog output from the git directories extracted to this path.") return cmd } @@ -78,6 +81,8 @@ type InfoOptions struct { ShowPullSpec bool Verify bool + ChangelogDir string + RegistryConfig string } @@ -111,6 +116,10 @@ func (o *InfoOptions) Complete(f kcmdutil.Factory, cmd *cobra.Command, args []st return fmt.Errorf("info expects at least one argument, a release image pull spec") } o.Images = args + if len(o.From) == 0 && len(o.Images) == 2 { + o.From = o.Images[0] + o.Images = o.Images[1:] + } return nil } @@ -118,15 +127,20 @@ func (o *InfoOptions) Validate() error { if len(o.ImageFor) > 0 && len(o.Output) > 0 { return fmt.Errorf("--output and --image-for may not both be specified") } + if len(o.ChangelogDir) > 0 { + if len(o.From) == 0 { + return fmt.Errorf("--changelog requires --from") + } + if len(o.ImageFor) > 0 || o.ShowCommit || o.ShowPullSpec || o.Verify { + return fmt.Errorf("--changelog may not be specified with any other flag except --from") + } + } switch o.Output { case "", "json": default: return fmt.Errorf("--output only supports 'json'") } - return nil -} -func (o *InfoOptions) Run() error { if len(o.Images) == 0 { return fmt.Errorf("must specify a release image as an argument") } @@ -134,6 +148,10 @@ func (o *InfoOptions) Run() error { return fmt.Errorf("must specify a single release image as argument when comparing to another release image") } + return nil +} + +func (o *InfoOptions) Run() error { if len(o.From) > 0 { var baseRelease *ReleaseInfo var baseErr error @@ -156,6 +174,9 @@ func (o *InfoOptions) Run() error { if err != nil { return err } + if len(o.ChangelogDir) > 0 { + return describeChangelog(o.Out, o.ErrOut, diff, o.ChangelogDir) + } return describeReleaseDiff(o.Out, diff, o.ShowCommit) } @@ -648,3 +669,167 @@ func digestOrRef(ref string) string { } return ref } + +func describeChangelog(out, errOut io.Writer, diff *ReleaseDiff, dir string) error { + if diff.To.Digest == diff.From.Digest { + fmt.Fprintf(out, "Releases are identical\n") + return nil + } + + fmt.Fprintf(out, heredoc.Docf(` + # %s + + ## Checksum + + SHA256 %s + + ## Changes from %s + + `, diff.To.PreferredName(), "`"+diff.To.Digest+"`", diff.From.PreferredName())) + + var hasError bool + + repoToCommitsToImages := make(map[string]map[string][]string) + var added, removed, imageChanged, codeChanged []string + for k, imageDiff := range diff.ChangedImages { + from, to := imageDiff.From, imageDiff.To + switch { + case from == nil: + added = append(added, k) + case to == nil: + removed = append(removed, k) + default: + newRepo := to.Annotations["io.openshift.build.source-location"] + oldCommit, newCommit := from.Annotations["io.openshift.build.commit.id"], to.Annotations["io.openshift.build.commit.id"] + if len(oldCommit) > 0 && oldCommit != newCommit { + commitRange, ok := repoToCommitsToImages[newRepo] + if !ok { + commitRange = make(map[string][]string) + repoToCommitsToImages[newRepo] = commitRange + } + rangeID := fmt.Sprintf("%s..%s", oldCommit, newCommit) + commitRange[rangeID] = append(commitRange[rangeID], k) + codeChanged = append(codeChanged, k) + break + } + if from.From != nil && to.From != nil { + if fromRef, err := imagereference.Parse(from.From.Name); err == nil { + if toRef, err := imagereference.Parse(to.From.Name); err == nil { + if len(fromRef.ID) > 0 && fromRef.ID == toRef.ID { + break + } + } + } + } + imageChanged = append(imageChanged, k) + } + } + + sort.Strings(added) + sort.Strings(removed) + sort.Strings(imageChanged) + sort.Strings(codeChanged) + + if len(added) > 0 { + fmt.Fprintf(out, "### New images\n\n") + for _, k := range added { + fmt.Fprintf(out, "* %s\n", k) + } + fmt.Fprintln(out) + } + + if len(removed) > 0 { + fmt.Fprintf(out, "### Removed images\n\n") + for _, k := range removed { + fmt.Fprintf(out, "* %s\n", k) + } + fmt.Fprintln(out) + } + + if len(imageChanged) > 0 { + fmt.Fprintf(out, "### Rebuilt images without code change\n\n") + for _, k := range imageChanged { + fmt.Fprintf(out, "* %s\n", k) + } + fmt.Fprintln(out) + } + + for _, key := range codeChanged { + imageDiff := diff.ChangedImages[key] + from, to := imageDiff.From, imageDiff.To + _, newRepo := from.Annotations["io.openshift.build.source-location"], to.Annotations["io.openshift.build.source-location"] + oldCommit, newCommit := from.Annotations["io.openshift.build.commit.id"], to.Annotations["io.openshift.build.commit.id"] + + // only display a given chunk of changes once + rangeID := fmt.Sprintf("%s..%s", oldCommit, newCommit) + allKeys := repoToCommitsToImages[newRepo][rangeID] + if len(allKeys) == 0 { + continue + } + repoToCommitsToImages[newRepo][rangeID] = nil + sort.Strings(allKeys) + + basePath, err := sourceLocationAsRelativePath(dir, newRepo) + if err != nil { + fmt.Fprintf(errOut, "warning: Unable to load change info for %s: %v\n\n", key, err) + hasError = true + continue + } + u, _ := sourceLocationAsURL(newRepo) + + g := &git{} + g, err = g.ChangeContext(basePath) + if err != nil { + fmt.Fprintf(errOut, "warning: %s is not a git repo: %v\n\n", basePath, err) + hasError = true + continue + } + commits, err := mergeLogForRepo(g, oldCommit, newCommit) + if err != nil { + fmt.Fprintf(errOut, "warning: Could not load commits for %s: %v\n\n", newRepo, err) + hasError = true + continue + } + if len(commits) > 0 { + fmt.Fprintf(out, "### %s\n\n", strings.Join(allKeys, ", ")) + for _, commit := range commits { + var suffix string + switch { + case commit.PullRequest > 0: + suffix = fmt.Sprintf("[#%d](%s)", commit.PullRequest, fmt.Sprintf("https://%s%s/pull/%d", u.Host, u.Path, commit.PullRequest)) + case u.Host == "github.com": + commit := commit.Commit[:8] + suffix = fmt.Sprintf("[%s](%s)", commit, fmt.Sprintf("https://%s%s/commit/%s", u.Host, u.Path, commit)) + default: + suffix = commit.Commit[:8] + } + switch { + case commit.Bug > 0: + fmt.Fprintf(out, + "* [Bug %d](%s): %s %s\n", + commit.Bug, + fmt.Sprintf("https://bugzilla.redhat.com/show_bug.cgi?id=%d", commit.Bug), + commit.Subject, + suffix, + ) + default: + fmt.Fprintf(out, + "* %s %s\n", + commit.Subject, + suffix, + ) + } + } + if u.Host == "github.com" { + fmt.Fprintf(out, "* [Full changelog](%s)\n\n", fmt.Sprintf("https://%s%s/compare/%s...%s", u.Host, u.Path, oldCommit, newCommit)) + } else { + fmt.Fprintf(out, "* %s from %s to %s\n\n", newRepo, oldCommit[:8], newCommit[:8]) + } + fmt.Fprintln(out) + } + } + if hasError { + return kcmdutil.ErrExit + } + return nil +}