mirror of https://gitlab.com/gitlab-org/cli.git
feat: add new changelog command
This commit is contained in:
parent
654ae7c117
commit
4f200f917e
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
```
|
|
@ -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
|
||||
```
|
|
@ -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)
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue