feat: add new changelog command

This commit is contained in:
Michael Mead 2023-05-17 15:26:35 +00:00 committed by Oscar Tovar
parent 654ae7c117
commit 4f200f917e
10 changed files with 473 additions and 0 deletions

16
api/changelog.go Normal file
View File

@ -0,0 +1,16 @@
package api
import "github.com/xanzy/go-gitlab"
var GenerateChangelog = func(client *gitlab.Client, projectID interface{}, options *gitlab.GenerateChangelogDataOptions) (*gitlab.ChangelogData, error) {
if client == nil {
client = apiClient.Lab()
}
changelog, _, err := client.Repositories.GenerateChangelogData(projectID, *options)
if err != nil {
return nil, err
}
return changelog, nil
}

View File

@ -0,0 +1,20 @@
package changelog
import (
"github.com/spf13/cobra"
changelogGenerateCmd "gitlab.com/gitlab-org/cli/commands/changelog/generate"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
)
func NewCmdChangelog(f *cmdutils.Factory) *cobra.Command {
changelogCmd := &cobra.Command{
Use: "changelog <command> [flags]",
Short: `Interact with the changelog API`,
Long: ``,
}
// Subcommands
changelogCmd.AddCommand(changelogGenerateCmd.NewCmdGenerate(f))
return changelogCmd
}

View File

@ -0,0 +1,116 @@
package generate
import (
"errors"
"fmt"
"time"
"github.com/MakeNowJust/heredoc"
"gitlab.com/gitlab-org/cli/api"
"github.com/spf13/cobra"
"github.com/xanzy/go-gitlab"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/pkg/git"
)
func NewCmdGenerate(f *cmdutils.Factory) *cobra.Command {
changelogGenerateCmd := &cobra.Command{
Use: "generate [flags]",
Short: `Generate a changelog for the repository/project`,
Long: ``,
Example: heredoc.Doc(`
glab changelog generate
`),
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
apiClient, err := f.HttpClient()
if err != nil {
return err
}
repo, err := f.BaseRepo()
if err != nil {
return err
}
opts := &gitlab.GenerateChangelogDataOptions{}
// Set the version
if s, _ := cmd.Flags().GetString("version"); s != "" {
opts.Version = gitlab.String(s)
} else {
tags, err := git.ListTags()
if err != nil {
return err
}
if len(tags) == 0 {
return errors.New("no tags found. Either fetch tags or pass a version with --version instead")
}
version, err := git.DescribeByTags()
if err != nil {
return fmt.Errorf("failed to determine version from git describe: %w..", err)
}
opts.Version = gitlab.String(version)
}
// Set the config file
if s, _ := cmd.Flags().GetString("config-file"); s != "" {
opts.ConfigFile = gitlab.String(s)
}
// Set the date
if s, _ := cmd.Flags().GetString("date"); s != "" {
parsedDate, err := time.Parse(time.RFC3339, s)
if err != nil {
return err
}
t := gitlab.ISOTime(parsedDate)
opts.Date = &t
}
// Set the "from" attribute
if s, _ := cmd.Flags().GetString("from"); s != "" {
opts.From = gitlab.String(s)
}
// Set the "to" attribute
if s, _ := cmd.Flags().GetString("to"); s != "" {
opts.To = gitlab.String(s)
}
// Set the trailer
if s, _ := cmd.Flags().GetString("trailer"); s != "" {
opts.Trailer = gitlab.String(s)
}
project, err := repo.Project(apiClient)
if err != nil {
return err
}
changelog, err := api.GenerateChangelog(apiClient, project.ID, opts)
if err != nil {
return err
}
fmt.Fprintf(f.IO.StdOut, "%s", changelog.Notes)
return nil
},
}
// The options mimic the ones from the REST API.
// https://docs.gitlab.com/ee/api/repositories.html#generate-changelog-data
changelogGenerateCmd.Flags().StringP("version", "v", "", "The version to generate the changelog for. The format must follow semantic versioning. Defaults to the version of the local checkout (like using git describe).")
changelogGenerateCmd.Flags().StringP("config-file", "", "", "The path of changelog configuration file in the project's Git repository. Defaults to .gitlab/changelog_config.yml.")
changelogGenerateCmd.Flags().StringP("date", "", "", "The date and time of the release. Uses ISO 8601 (2016-03-11T03:45:40Z) format. Defaults to the current time.")
changelogGenerateCmd.Flags().StringP("from", "", "", "The start of the range of commits (as a SHA) to use for generating the changelog. This commit itself isn't included in the list.")
changelogGenerateCmd.Flags().StringP("to", "", "", "The end of the range of commits (as a SHA) to use for the changelog. This commit is included in the list. Defaults to the HEAD of the default project branch.")
changelogGenerateCmd.Flags().StringP("trailer", "", "", "The Git trailer to use for including commits. Defaults to Changelog.")
return changelogGenerateCmd
}

View File

@ -0,0 +1,85 @@
package generate
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/cli/commands/cmdtest"
"gitlab.com/gitlab-org/cli/pkg/httpmock"
"gitlab.com/gitlab-org/cli/test"
)
func runCommand(cli string, rt http.RoundTripper) (*test.CmdOut, error) {
ios, _, stdout, stderr := cmdtest.InitIOStreams(false, "")
factory := cmdtest.InitFactory(ios, rt)
_, _ = factory.HttpClient()
cmd := NewCmdGenerate(factory)
return cmdtest.ExecuteCommand(cmd, cli, stdout, stderr)
}
func TestChangelogGenerate(t *testing.T) {
fakeHTTP := &httpmock.Mocker{MatchURL: httpmock.PathAndQuerystring}
defer fakeHTTP.Verify(t)
// Mock the project ID
fakeHTTP.RegisterResponder(http.MethodGet, "/api/v4/projects/OWNER/REPO?license=true&statistics=true&with_custom_attributes=true",
httpmock.NewStringResponse(http.StatusOK, `{ "id": 37777023 }`))
// Mock the acutal changelog API call
// TODO: mock the other optional attributes that we can pass to the endpoint.
fakeHTTP.RegisterResponder(http.MethodGet, "/api/v4/projects/37777023/repository/changelog?version=1.0.0",
httpmock.NewStringResponse(http.StatusOK, `{
"notes": "## 1.0.0 (2023-04-02)\n\n### FirstName LastName firstname@lastname.com (1 changes)\n\n- [inital commit](gitlab-org/cli@somehash ([merge request](gitlab-org/cli!1))\n"
}`))
output, err := runCommand("--version 1.0.0", fakeHTTP)
require.Nil(t, err)
assert.Empty(t, output.Stderr())
expectedStr := "## 1.0.0 (2023-04-02)\n\n### FirstName LastName firstname@lastname.com (1 changes)\n\n- [inital commit](gitlab-org/cli@somehash ([merge request](gitlab-org/cli!1))\n"
assert.Equal(t, expectedStr, output.String())
}
func TestChangelogGenerateWithError(t *testing.T) {
cases := map[string]struct {
httpStatus int
httpMsgJSON string
errorMsg string
}{
"unauthorized": {
httpStatus: http.StatusUnauthorized,
httpMsgJSON: "{message: 401 Unauthorized}",
errorMsg: "GET https://gitlab.com/api/v4/projects/37777023/repository/changelog: 401 failed to parse unknown error format: {message: 401 Unauthorized}",
},
"not found": {
httpStatus: http.StatusNotFound,
httpMsgJSON: "{message: 404 Project Not Found}",
errorMsg: "GET https://gitlab.com/api/v4/projects/37777023/repository/changelog: 404 failed to parse unknown error format: {message: 404 Project Not Found}",
},
}
for name, v := range cases {
t.Run(name, func(t *testing.T) {
fakeHTTP := &httpmock.Mocker{MatchURL: httpmock.PathAndQuerystring}
defer fakeHTTP.Verify(t)
// Mock the project ID
fakeHTTP.RegisterResponder(http.MethodGet, "/api/v4/projects/OWNER/REPO?license=true&statistics=true&with_custom_attributes=true",
httpmock.NewStringResponse(http.StatusOK, `{ "id": 37777023 }`))
fakeHTTP.RegisterResponder(http.MethodGet, "/api/v4/projects/37777023/repository/changelog?version=1.0.0",
httpmock.NewStringResponse(v.httpStatus, v.httpMsgJSON))
_, err := runCommand("--version 1.0.0", fakeHTTP)
require.NotNil(t, err)
require.Equal(t, v.errorMsg, err.Error())
})
}
}

View File

@ -10,6 +10,7 @@ import (
apiCmd "gitlab.com/gitlab-org/cli/commands/api"
askCmd "gitlab.com/gitlab-org/cli/commands/ask"
authCmd "gitlab.com/gitlab-org/cli/commands/auth"
changelogCmd "gitlab.com/gitlab-org/cli/commands/changelog"
pipelineCmd "gitlab.com/gitlab-org/cli/commands/ci"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
completionCmd "gitlab.com/gitlab-org/cli/commands/completion"
@ -108,6 +109,7 @@ func NewCmdRoot(f *cmdutils.Factory, version, buildDate string) *cobra.Command {
f.BaseRepo = resolvedBaseRepo(f)
cmdutils.HTTPClientFactory(f) // Initialize HTTP Client
rootCmd.AddCommand(changelogCmd.NewCmdChangelog(f))
rootCmd.AddCommand(issueCmd.NewCmdIssue(f))
rootCmd.AddCommand(incidentCmd.NewCmdIncident(f))
rootCmd.AddCommand(labelCmd.NewCmdLabel(f))

View File

@ -0,0 +1,42 @@
---
stage: Create
group: Code Review
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
---
<!--
This documentation is auto generated by a script.
Please do not edit this file directly. Run `make gen-docs` instead.
-->
# `glab changelog generate`
Generate a changelog for the repository/project
```plaintext
glab changelog generate [flags]
```
## Examples
```plaintext
glab changelog generate
```
## Options
```plaintext
--config-file string The path of changelog configuration file in the project's Git repository. Defaults to .gitlab/changelog_config.yml.
--date string The date and time of the release. Uses ISO 8601 (2016-03-11T03:45:40Z) format. Defaults to the current time.
--from string The start of the range of commits (as a SHA) to use for generating the changelog. This commit itself isn't included in the list.
--to string The end of the range of commits (as a SHA) to use for the changelog. This commit is included in the list. Defaults to the HEAD of the default project branch.
--trailer string The Git trailer to use for including commits. Defaults to Changelog.
-v, --version string The version to generate the changelog for. The format must follow semantic versioning. Defaults to the version of the local checkout (like using git describe).
```
## Options inherited from parent commands
```plaintext
--help Show help for command
```

View File

@ -0,0 +1,24 @@
---
stage: Create
group: Code Review
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
---
<!--
This documentation is auto generated by a script.
Please do not edit this file directly. Run `make gen-docs` instead.
-->
# `glab changelog help`
Help about any command
```plaintext
glab changelog help [command] [flags]
```
## Options inherited from parent commands
```plaintext
--help Show help for command
```

View File

@ -0,0 +1,24 @@
---
stage: Create
group: Code Review
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
---
<!--
This documentation is auto generated by a script.
Please do not edit this file directly. Run `make gen-docs` instead.
-->
# `glab changelog`
Interact with the changelog API
## Options inherited from parent commands
```plaintext
--help Show help for command
```
## Subcommands
- [generate](generate.md)

View File

@ -491,3 +491,34 @@ func RunCmd(args []string) (err error) {
err = run.PrepareCmd(gitCmd).Run()
return
}
// DescribeByTags gives a description of the current object.
// Non-annotated tags are considered.
// Reference: https://git-scm.com/docs/git-describe
func DescribeByTags() (string, error) {
gitCmd := GitCommand("describe", "--tags")
output, err := run.PrepareCmd(gitCmd).Output()
if err != nil {
return "", fmt.Errorf("running cmd: %s out: %s: %w", gitCmd.String(), output, err)
}
return string(output), nil
}
// ListTags gives a slice of tags from the current repository.
func ListTags() ([]string, error) {
gitCmd := GitCommand("tag", "-l")
output, err := run.PrepareCmd(gitCmd).Output()
if err != nil {
return nil, fmt.Errorf("running cmd: %s out: %s: %w", gitCmd.String(), output, err)
}
tagsStr := string(output)
if tagsStr == "" {
return nil, nil
}
return strings.Fields(tagsStr), nil
}

View File

@ -1,6 +1,7 @@
package git
import (
"errors"
"os"
"os/exec"
"reflect"
@ -326,3 +327,115 @@ func TestGetRemoteURL(t *testing.T) {
})
}
}
func TestDescribeByTags(t *testing.T) {
cases := map[string]struct {
expected string
output string
errorValue error
}{
"invalid repository": {
expected: "",
output: "",
errorValue: errors.New("fatal: not a git repository (or any of the parent directories): .git"),
},
"commit is tag": {
expected: "1.0.0",
output: "1.0.0",
errorValue: nil,
},
"commit is not tag": {
expected: "1.0.0-1-g4aa1b8",
output: "1.0.0-1-g4aa1b8",
errorValue: nil,
},
}
t.Cleanup(func() {
teardown := run.SetPrepareCmd(func(*exec.Cmd) run.Runnable {
return &test.OutputStub{}
})
teardown()
})
for name, v := range cases {
t.Run(name, func(t *testing.T) {
_ = run.SetPrepareCmd(func(*exec.Cmd) run.Runnable {
return &test.OutputStub{Out: []byte(v.output), Error: v.errorValue}
})
version, err := DescribeByTags()
require.Equal(t, v.errorValue, errors.Unwrap(err))
require.Equal(t, v.expected, version, "unexpected version value for case %s", name)
})
}
}
func TestListTags(t *testing.T) {
cases := map[string]struct {
expected []string
output string
errorVal error
}{
"invalid repository": {
expected: nil,
output: "",
errorVal: errors.New("fatal: not a git repository (or any of the parent directories): .git"),
},
"no tags": {
expected: nil,
output: "",
errorVal: nil,
},
"no tags w/ extra newline": {
expected: []string{},
output: "\n",
errorVal: nil,
},
"single semver tag": {
expected: []string{"1.0.0"},
output: "1.0.0",
errorVal: nil,
},
"multiple semver tags": {
expected: []string{"1.0.0", "2.0.0", "3.0.0"},
output: "1.0.0\n2.0.0\n3.0.0",
errorVal: nil,
},
"multiple semver tags with extra newlines": {
expected: []string{"1.0.0", "2.0.0", "3.0.0"},
output: "1.0.0\n2.0.0\n3.0.0\n\n",
errorVal: nil,
},
"single non-semver tag": {
expected: []string{"a"},
output: "a",
errorVal: nil,
},
"multiple non-semver tag": {
expected: []string{"a", "b"},
output: "a\nb",
errorVal: nil,
},
}
t.Cleanup(func() {
teardown := run.SetPrepareCmd(func(*exec.Cmd) run.Runnable {
return &test.OutputStub{}
})
teardown()
})
for name, v := range cases {
t.Run(name, func(t *testing.T) {
_ = run.SetPrepareCmd(func(*exec.Cmd) run.Runnable {
return &test.OutputStub{Out: []byte(v.output), Error: v.errorVal}
})
tags, err := ListTags()
require.Equal(t, v.errorVal, errors.Unwrap(err))
require.Equal(t, v.expected, tags)
})
}
}