diff --git a/docs/source/_index.md b/docs/source/_index.md index a64dccf2b697d169dae67d9168047f1b8eff0a65..ed2e81bfde8ce11a322550ada1e45198fbf5f268 100644 --- a/docs/source/_index.md +++ b/docs/source/_index.md @@ -91,6 +91,7 @@ in the main README. - [`glab job`](job/_index.md) - [`glab label`](label/_index.md) - [`glab mcp`](mcp/_index.md) +- [`glab milestone`](milestone/_index.md) - [`glab mr`](mr/_index.md) - [`glab opentofu`](opentofu/_index.md) - [`glab release`](release/_index.md) diff --git a/docs/source/milestone/_index.md b/docs/source/milestone/_index.md new file mode 100644 index 0000000000000000000000000000000000000000..7c80a1c7f4762bdf3a8911c1008f67dfaee18bc3 --- /dev/null +++ b/docs/source/milestone/_index.md @@ -0,0 +1,31 @@ +--- +title: glab milestone +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 +--- + + + +Manage group or project milestones. + +## Options + +```plaintext + -R, --repo OWNER/REPO Select another repository. Can use either OWNER/REPO or `GROUP/NAMESPACE/REPO` format. Also accepts full URL or Git URL. +``` + +## Options inherited from parent commands + +```plaintext + -h, --help Show help for this command. +``` + +## Subcommands + +- [`create`](create.md) +- [`get`](get.md) +- [`list`](list.md) diff --git a/docs/source/milestone/create.md b/docs/source/milestone/create.md new file mode 100644 index 0000000000000000000000000000000000000000..f19ec331edf04bc1e3f736e7f8c67ea1d763b751 --- /dev/null +++ b/docs/source/milestone/create.md @@ -0,0 +1,49 @@ +--- +title: glab milestone create +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 +--- + + + +Create a group or project milestone. + +```plaintext +glab milestone create [flags] +``` + +## Examples + +```console + # Create milestone for the current project +$ glab milestone create --title='Example title' --due-date='2025-12-16' + +# Create milestone for the specified project +$ glab milestone create --title='Example group milestone' --due-date='2025-12-16' --project 123 + +# Create milestone for the specified group +$ glab milestone create --title='Example group milestone' --due-date='2025-12-16' --group 456 + +``` + +## Options + +```plaintext + --description string Description of the milestone. + --due-date string Due date for the milestone. Expected in ISO 8601 format (2025-04-15T08:00:00Z). + --group string The ID or URL-encoded path of the group. + --project string The ID or URL-encoded path of the project. + --start-date string Start date for the milestone. Expected in ISO 8601 format (2025-04-15T08:00:00Z). + --title string Title of the milestone. +``` + +## Options inherited from parent commands + +```plaintext + -h, --help Show help for this command. + -R, --repo OWNER/REPO Select another repository. Can use either OWNER/REPO or `GROUP/NAMESPACE/REPO` format. Also accepts full URL or Git URL. +``` diff --git a/docs/source/milestone/get.md b/docs/source/milestone/get.md new file mode 100644 index 0000000000000000000000000000000000000000..36de021f8248702d759af4e3e825ea235c1cd2d2 --- /dev/null +++ b/docs/source/milestone/get.md @@ -0,0 +1,45 @@ +--- +title: glab milestone get +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 +--- + + + +Get a milestones via an ID for a project or group. + +```plaintext +glab milestone get [flags] +``` + +## Examples + +```console + # Get milestone for the current project +$ glab milestone get 123 + +# Get milestone for the specified project +$ glab milestone get 123 --project project-name + +# Get milestone for the specified group +$ glab milestone get 123 --group group-name + +``` + +## Options + +```plaintext + --group string The ID or URL-encoded path of the group. + --project string The ID or URL-encoded path of the project. +``` + +## Options inherited from parent commands + +```plaintext + -h, --help Show help for this command. + -R, --repo OWNER/REPO Select another repository. Can use either OWNER/REPO or `GROUP/NAMESPACE/REPO` format. Also accepts full URL or Git URL. +``` diff --git a/docs/source/milestone/list.md b/docs/source/milestone/list.md new file mode 100644 index 0000000000000000000000000000000000000000..96de1f1f4e82531f30442ab246b5548e74686323 --- /dev/null +++ b/docs/source/milestone/list.md @@ -0,0 +1,53 @@ +--- +title: glab milestone list +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 +--- + + + +Get a list of milestones for a project or group. + +```plaintext +glab milestone list [flags] +``` + +## Examples + +```console + # List milestones for a given project +$ glab milestone list --project 123 +$ glab milestone list --project example-group/project-path + +# List milestones for a group +$ glab milestone list --group example-group + +# List only active milestones for a given group +$ glab milestone list --group example-group --state active + +``` + +## Options + +```plaintext + --group string The ID or URL-encoded path of the group. + --include-ancestors Include milestones from all parent groups. + -p, --page int Page number. (default 1) + -P, --per-page int Number of items to list per page. (default 20) + --project string The ID or URL-encoded path of the project. + --search string Return only milestones with a title or description matching the provided string. + --show-id Show IDs in table output. + --state string Return only 'active' or 'closed' milestones. + --title string Return only the milestones having the given title. +``` + +## Options inherited from parent commands + +```plaintext + -h, --help Show help for this command. + -R, --repo OWNER/REPO Select another repository. Can use either OWNER/REPO or `GROUP/NAMESPACE/REPO` format. Also accepts full URL or Git URL. +``` diff --git a/internal/commands/milestone/create/create.go b/internal/commands/milestone/create/create.go new file mode 100644 index 0000000000000000000000000000000000000000..49b350aa2ddfd9ca3344f4abc401e5599cb811b7 --- /dev/null +++ b/internal/commands/milestone/create/create.go @@ -0,0 +1,157 @@ +package create + +import ( + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" + gitlab "gitlab.com/gitlab-org/api/client-go" + "gitlab.com/gitlab-org/cli/internal/api" + "gitlab.com/gitlab-org/cli/internal/cmdutils" + "gitlab.com/gitlab-org/cli/internal/glrepo" + "gitlab.com/gitlab-org/cli/internal/iostreams" + "gitlab.com/gitlab-org/cli/internal/mcpannotations" +) + +type options struct { + apiClient func(repoHost string) (*api.Client, error) + io *iostreams.IOStreams + baseRepo func() (glrepo.Interface, error) + + projectID string + groupID string + + title string + description string + dueDate string + startDate string +} + +func NewCmdCreate(f cmdutils.Factory) *cobra.Command { + opts := &options{ + io: f.IO(), + apiClient: f.ApiClient, + baseRepo: f.BaseRepo, + } + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a group or project milestone.", + Long: "", + Example: heredoc.Doc(` + # Create milestone for the current project + $ glab milestone create --title='Example title' --due-date='2025-12-16' + + # Create milestone for the specified project + $ glab milestone create --title='Example group milestone' --due-date='2025-12-16' --project 123 + + # Create milestone for the specified group + $ glab milestone create --title='Example group milestone' --due-date='2025-12-16' --group 456 + `), + Args: cobra.MaximumNArgs(0), + Annotations: map[string]string{ + mcpannotations.Safe: "false", + }, + RunE: func(cmd *cobra.Command, args []string) error { + return opts.run() + }, + } + + cmd.Flags().StringVar(&opts.projectID, "project", "", "The ID or URL-encoded path of the project.") + cmd.Flags().StringVar(&opts.groupID, "group", "", "The ID or URL-encoded path of the group.") + + cmd.Flags().StringVar(&opts.title, "title", "", "Title of the milestone.") + cmd.Flags().StringVar(&opts.description, "description", "", "Description of the milestone.") + cmd.Flags().StringVar(&opts.dueDate, "due-date", "", "Due date for the milestone. Expected in ISO 8601 format (2025-04-15T08:00:00Z).") + cmd.Flags().StringVar(&opts.startDate, "start-date", "", "Start date for the milestone. Expected in ISO 8601 format (2025-04-15T08:00:00Z).") + + cobra.CheckErr(cmd.MarkFlagRequired("title")) + + return cmd +} + +func (o *options) run() error { + c, err := o.apiClient("") + if err != nil { + return err + } + client := c.Lab() + + var parsedDueDate, parsedStartDate gitlab.ISOTime + + if o.startDate != "" { + if parsedStartDate, err = gitlab.ParseISOTime(o.startDate); err != nil { + return err + } + } + + if o.dueDate != "" { + if parsedDueDate, err = gitlab.ParseISOTime(o.dueDate); err != nil { + return err + } + } + + if o.projectID != "" { + createMilestoneOptions := &gitlab.CreateMilestoneOptions{ + Title: &o.title, + Description: &o.description, + } + + if o.startDate != "" { + createMilestoneOptions.StartDate = &parsedStartDate + } + + if o.dueDate != "" { + createMilestoneOptions.DueDate = &parsedDueDate + } + + milestone, _, err := client.Milestones.CreateMilestone(o.projectID, createMilestoneOptions) + if err != nil { + return err + } + + o.io.LogInfof("Created project milestone %s (ID: %d)", milestone.Title, milestone.ID) + return nil + } else if o.groupID != "" { // get group milestone + createGroupMilestoneOptions := &gitlab.CreateGroupMilestoneOptions{ + Title: &o.title, + Description: &o.description, + } + + if o.startDate != "" { + createGroupMilestoneOptions.StartDate = &parsedStartDate + } + + if o.dueDate != "" { + createGroupMilestoneOptions.DueDate = &parsedDueDate + } + + milestone, _, err := client.GroupMilestones.CreateGroupMilestone(o.groupID, createGroupMilestoneOptions) + if err != nil { + return err + } + + o.io.LogInfof("Created group milestone %s (ID: %d)", milestone.Title, milestone.ID) + return nil + } + + // run for the current project + repo, _ := o.baseRepo() + createMilestoneOptions := &gitlab.CreateMilestoneOptions{ + Title: &o.title, + Description: &o.description, + } + + if o.startDate != "" { + createMilestoneOptions.StartDate = &parsedStartDate + } + if o.dueDate != "" { + createMilestoneOptions.DueDate = &parsedDueDate + } + + milestone, _, err := client.Milestones.CreateMilestone(repo.FullName(), createMilestoneOptions) + if err != nil { + return err + } + + o.io.LogInfof("Created project milestone %s (ID: %d)", milestone.Title, milestone.ID) + return nil +} diff --git a/internal/commands/milestone/create/create_test.go b/internal/commands/milestone/create/create_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b958e024283edaf1a1e935dd48435e410bdd60c1 --- /dev/null +++ b/internal/commands/milestone/create/create_test.go @@ -0,0 +1,162 @@ +package create + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gitlab "gitlab.com/gitlab-org/api/client-go" + gitlabtesting "gitlab.com/gitlab-org/api/client-go/testing" + "gitlab.com/gitlab-org/cli/internal/api" + "gitlab.com/gitlab-org/cli/internal/testing/cmdtest" + "go.uber.org/mock/gomock" +) + +func Test_CreateProjectMilestone(t *testing.T) { + type testCase struct { + Name string + ExpectedMsg []string + wantErr bool + cli string + wantStderr string + setupMock func(tc *gitlabtesting.TestClient) + } + + testMilestone := &gitlab.Milestone{ + ID: 123, + ProjectID: 456, + Title: "Example title", + Description: "Example description", + State: "active", + DueDate: gitlab.Ptr(gitlab.ISOTime(time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC))), + } + + testCases := []testCase{ + { + Name: "Create project milestone", + ExpectedMsg: []string{"Created project milestone Example title (ID: 123)"}, + cli: "--title='Example title' --project=456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockMilestones.EXPECT().CreateMilestone("456", gomock.Any()).Return(testMilestone, nil, nil) + }, + }, + { + Name: "Create project milestone with specific due date", + ExpectedMsg: []string{"Created project milestone Example title (ID: 123)"}, + cli: "--title='Example title' --due-date='2025-12-16' --project 456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockMilestones.EXPECT().CreateMilestone("456", gomock.Any()).Return(testMilestone, nil, nil) + }, + }, + { + Name: "Should return an error if title is not supplied", + wantErr: true, + wantStderr: "required flag(s) \"title\" not set", + cli: "--due-date='2025-12-16' --project 456", + setupMock: func(tc *gitlabtesting.TestClient) {}, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // GIVEN + testClient := gitlabtesting.NewTestClient(t) + tc.setupMock(testClient) + exec := cmdtest.SetupCmdForTest( + t, + NewCmdCreate, + false, + cmdtest.WithApiClient(cmdtest.NewTestApiClient(t, nil, "", "", api.WithGitLabClient(testClient.Client))), + ) + + // WHEN + out, err := exec(tc.cli) + + // THEN + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantStderr) + return + } + require.NoError(t, err) + for _, msg := range tc.ExpectedMsg { + assert.Equal(t, msg, out.OutBuf.String()) + } + }) + } +} + +func Test_CreateGroupMilestone(t *testing.T) { + type testCase struct { + Name string + ExpectedMsg []string + wantErr bool + cli string + wantStderr string + setupMock func(tc *gitlabtesting.TestClient) + } + + testMilestone := &gitlab.GroupMilestone{ + ID: 123, + GroupID: 456, + Title: "Example title", + Description: "Example description", + State: "active", + DueDate: gitlab.Ptr(gitlab.ISOTime(time.Date(2025, 12, 16, 0, 0, 0, 0, time.UTC))), + } + + testCases := []testCase{ + { + Name: "Create group milestone", + ExpectedMsg: []string{"Created group milestone Example title (ID: 123)"}, + cli: "--title='Example title' --group=456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockGroupMilestones.EXPECT().CreateGroupMilestone("456", gomock.Any()).Return(testMilestone, nil, nil) + }, + }, + { + Name: "Create group milestone with specific due date", + ExpectedMsg: []string{"Created group milestone Example title (ID: 123)"}, + cli: "--title='Example title' --due-date='2025-12-16' --group 456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockGroupMilestones.EXPECT().CreateGroupMilestone("456", gomock.Any()).Return(testMilestone, nil, nil) + }, + }, + { + Name: "Should return an error if title is not supplied", + wantErr: true, + wantStderr: "required flag(s) \"title\" not set", + cli: "--due-date='2025-12-16' --group 456", + setupMock: func(tc *gitlabtesting.TestClient) {}, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // GIVEN + testClient := gitlabtesting.NewTestClient(t) + tc.setupMock(testClient) + exec := cmdtest.SetupCmdForTest( + t, + NewCmdCreate, + false, + cmdtest.WithApiClient(cmdtest.NewTestApiClient(t, nil, "", "", api.WithGitLabClient(testClient.Client))), + ) + + // WHEN + out, err := exec(tc.cli) + + // THEN + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantStderr) + return + } + require.NoError(t, err) + for _, msg := range tc.ExpectedMsg { + assert.Equal(t, msg, out.OutBuf.String()) + } + }) + } +} diff --git a/internal/commands/milestone/get/get.go b/internal/commands/milestone/get/get.go new file mode 100644 index 0000000000000000000000000000000000000000..68b874c9fb43c7636e9b577769240d85fd5a8df4 --- /dev/null +++ b/internal/commands/milestone/get/get.go @@ -0,0 +1,104 @@ +package get + +import ( + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" + "gitlab.com/gitlab-org/cli/internal/api" + "gitlab.com/gitlab-org/cli/internal/cmdutils" + "gitlab.com/gitlab-org/cli/internal/glrepo" + "gitlab.com/gitlab-org/cli/internal/iostreams" + "gitlab.com/gitlab-org/cli/internal/mcpannotations" + "gitlab.com/gitlab-org/cli/internal/utils" +) + +type options struct { + apiClient func(repoHost string) (*api.Client, error) + io *iostreams.IOStreams + baseRepo func() (glrepo.Interface, error) + + projectID string + groupID string + milestoneID int +} + +func NewCmdGet(f cmdutils.Factory) *cobra.Command { + opts := &options{ + io: f.IO(), + apiClient: f.ApiClient, + baseRepo: f.BaseRepo, + } + cmd := &cobra.Command{ + Use: "get", + Short: "Get a milestones via an ID for a project or group.", + Long: "", + Example: heredoc.Doc(` + # Get milestone for the current project + $ glab milestone get 123 + + # Get milestone for the specified project + $ glab milestone get 123 --project project-name + + # Get milestone for the specified group + $ glab milestone get 123 --group group-name + `), + Args: cobra.MaximumNArgs(1), + Annotations: map[string]string{ + mcpannotations.Safe: "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + var err error + if len(args) == 1 { + opts.milestoneID, err = strconv.Atoi(args[0]) + if err != nil { + return err + } + } + + return opts.run() + }, + } + + cmd.Flags().StringVar(&opts.projectID, "project", "", "The ID or URL-encoded path of the project.") + cmd.Flags().StringVar(&opts.groupID, "group", "", "The ID or URL-encoded path of the group.") + + return cmd +} + +func (o *options) run() error { + c, err := o.apiClient("") + if err != nil { + return err + } + client := c.Lab() + + if o.projectID != "" { // get project milestone + milestone, _, err := client.Milestones.GetMilestone(o.projectID, o.milestoneID) + if err != nil { + return err + } + + o.io.LogInfo(fmt.Sprintf("Title: %s\nDescription: %s\nState: %s\nDue Date: %s\n", milestone.Title, milestone.Description, milestone.State, utils.FormatDueDate(milestone.DueDate))) + return nil + } else if o.groupID != "" { // get group milestone + milestone, _, err := client.GroupMilestones.GetGroupMilestone(o.groupID, o.milestoneID) + if err != nil { + return err + } + + o.io.LogInfo(fmt.Sprintf("Title: %s\nDescription: %s\nState: %s\nDue Date: %s\n", milestone.Title, milestone.Description, milestone.State, utils.FormatDueDate(milestone.DueDate))) + return nil + } + + // run for the current project + repo, _ := o.baseRepo() + milestone, _, err := client.Milestones.GetMilestone(repo.FullName(), o.milestoneID) + if err != nil { + return err + } + + o.io.LogInfo(fmt.Sprintf("Title: %s\nDescription: %s\nState: %s\nDue Date: %s\n", milestone.Title, milestone.Description, milestone.State, utils.FormatDueDate(milestone.DueDate))) + return nil +} diff --git a/internal/commands/milestone/get/get_test.go b/internal/commands/milestone/get/get_test.go new file mode 100644 index 0000000000000000000000000000000000000000..8f4a375278ee1570560f2b4ad97795442aeac1a3 --- /dev/null +++ b/internal/commands/milestone/get/get_test.go @@ -0,0 +1,149 @@ +package get + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gitlab "gitlab.com/gitlab-org/api/client-go" + gitlabtesting "gitlab.com/gitlab-org/api/client-go/testing" + "gitlab.com/gitlab-org/cli/internal/api" + "gitlab.com/gitlab-org/cli/internal/testing/cmdtest" + "go.uber.org/mock/gomock" +) + +func Test_GetProjectMilestone(t *testing.T) { + type testCase struct { + Name string + ExpectedMsg []string + wantErr bool + cli string + wantStderr string + setupMock func(tc *gitlabtesting.TestClient) + } + + testMilestone := &gitlab.Milestone{ + ID: 123, + ProjectID: 456, + Title: "Milestone title", + Description: "Example description", + State: "closed", + DueDate: gitlab.Ptr(gitlab.ISOTime(time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC))), + } + + testCases := []testCase{ + { + Name: "Get project milestone", + ExpectedMsg: []string{"Title: Milestone title\nDescription: Example description\nState: closed\nDue Date: 2025-01-15\n\n"}, + cli: "123 --project 456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockMilestones.EXPECT().GetMilestone("456", 123).Return(testMilestone, nil, nil) + }, + }, + { + Name: "When milestone is not found returns an error", + wantErr: true, + cli: "111 --project 456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockMilestones.EXPECT().GetMilestone("456", 111).Return(nil, nil, errors.New("404 Not found")) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // GIVEN + testClient := gitlabtesting.NewTestClient(t) + tc.setupMock(testClient) + exec := cmdtest.SetupCmdForTest( + t, + NewCmdGet, + false, + cmdtest.WithApiClient(cmdtest.NewTestApiClient(t, nil, "", "", api.WithGitLabClient(testClient.Client))), + ) + + // WHEN + out, err := exec(tc.cli) + + // THEN + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantStderr) + return + } + require.NoError(t, err) + for _, msg := range tc.ExpectedMsg { + assert.Equal(t, msg, out.OutBuf.String()) + } + }) + } +} + +func Test_GetGroupMilestone(t *testing.T) { + type testCase struct { + Name string + ExpectedMsg []string + wantErr bool + cli string + wantStderr string + setupMock func(tc *gitlabtesting.TestClient) + } + + testMilestone := &gitlab.GroupMilestone{ + ID: 123, + GroupID: 456, + Title: "Milestone title", + Description: "Example description", + State: "closed", + DueDate: gitlab.Ptr(gitlab.ISOTime(time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC))), + } + + testCases := []testCase{ + { + Name: "Get group milestone", + ExpectedMsg: []string{"Title: Milestone title\nDescription: Example description\nState: closed\nDue Date: 2025-01-15\n\n"}, + cli: "123 --group 456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockGroupMilestones.EXPECT().GetGroupMilestone(gomock.Any(), 123).Return(testMilestone, nil, nil) + }, + }, + { + Name: "When milestone is not found returns an error", + wantErr: true, + cli: "111 --group 456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockGroupMilestones.EXPECT().GetGroupMilestone(gomock.Any(), 111).Return(nil, nil, errors.New("404 Not found")) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // GIVEN + testClient := gitlabtesting.NewTestClient(t) + tc.setupMock(testClient) + exec := cmdtest.SetupCmdForTest( + t, + NewCmdGet, + false, + cmdtest.WithApiClient(cmdtest.NewTestApiClient(t, nil, "", "", api.WithGitLabClient(testClient.Client))), + ) + + // WHEN + out, err := exec(tc.cli) + + // THEN + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantStderr) + return + } + require.NoError(t, err) + for _, msg := range tc.ExpectedMsg { + assert.Equal(t, msg, out.OutBuf.String()) + } + }) + } +} diff --git a/internal/commands/milestone/list/list.go b/internal/commands/milestone/list/list.go new file mode 100644 index 0000000000000000000000000000000000000000..0c378c5b3dd204dc22ed8f839d699c29df392748 --- /dev/null +++ b/internal/commands/milestone/list/list.go @@ -0,0 +1,181 @@ +package list + +import ( + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" + gitlab "gitlab.com/gitlab-org/api/client-go" + "gitlab.com/gitlab-org/cli/internal/api" + "gitlab.com/gitlab-org/cli/internal/cmdutils" + "gitlab.com/gitlab-org/cli/internal/iostreams" + "gitlab.com/gitlab-org/cli/internal/mcpannotations" + "gitlab.com/gitlab-org/cli/internal/tableprinter" + "gitlab.com/gitlab-org/cli/internal/utils" +) + +type options struct { + apiClient func(repoHost string) (*api.Client, error) + io *iostreams.IOStreams + + // Pagination + page int + perPage int + + title string + search string + state string + includeAncestors bool + + groupID string + projectID string + showIDs bool +} + +func NewCmdList(f cmdutils.Factory) *cobra.Command { + opts := &options{ + io: f.IO(), + apiClient: f.ApiClient, + } + cmd := &cobra.Command{ + Use: "list", + Short: "Get a list of milestones for a project or group.", + Long: "", + Example: heredoc.Doc(` + # List milestones for a given project + $ glab milestone list --project 123 + $ glab milestone list --project example-group/project-path + + # List milestones for a group + $ glab milestone list --group example-group + + # List only active milestones for a given group + $ glab milestone list --group example-group --state active + `), + Args: cobra.MaximumNArgs(0), + Annotations: map[string]string{ + mcpannotations.Safe: "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + return opts.run(cmd) + }, + } + + cmd.Flags().StringVar(&opts.projectID, "project", "", "The ID or URL-encoded path of the project.") + cmd.Flags().StringVar(&opts.groupID, "group", "", "The ID or URL-encoded path of the group.") + + cmd.Flags().StringVar(&opts.title, "title", "", "Return only the milestones having the given title.") + cmd.Flags().StringVar(&opts.search, "search", "", "Return only milestones with a title or description matching the provided string.") + cmd.Flags().StringVar(&opts.state, "state", "", "Return only 'active' or 'closed' milestones.") + cmd.Flags().BoolVar(&opts.includeAncestors, "include-ancestors", false, "Include milestones from all parent groups.") + + cmd.Flags().IntVarP(&opts.page, "page", "p", 1, "Page number.") + cmd.Flags().IntVarP(&opts.perPage, "per-page", "P", 20, "Number of items to list per page.") + cmd.Flags().BoolVar(&opts.showIDs, "show-id", false, "Show IDs in table output.") + + cmd.MarkFlagsOneRequired("project", "group") + + return cmd +} + +func (o *options) run(cmd *cobra.Command) error { + c, err := o.apiClient("") + if err != nil { + return err + } + client := c.Lab() + table := tableprinter.NewTablePrinter() + if o.showIDs { + table.AddRow("ID", "Title", "Description", "State", "Due Date") + } else { + table.AddRow("Title", "Description", "State", "Due Date") + } + + if o.projectID != "" { // list project milestones + listMilestonesOptions := &gitlab.ListMilestonesOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: o.perPage, + Page: o.page, + }, + } + + if o.title != "" { + listMilestonesOptions.Title = &o.title + } + if o.search != "" { + listMilestonesOptions.Search = &o.search + } + if o.state != "" { + listMilestonesOptions.State = &o.state + } + if cmd.Flags().Changed("include-ancestors") { + listMilestonesOptions.IncludeAncestors = &o.includeAncestors + } + + milestones, _, err := client.Milestones.ListMilestones(o.projectID, listMilestonesOptions) + if err != nil { + return err + } + + if len(milestones) == 0 { + o.io.LogInfo("No milestones found.") + return nil + } + + if o.showIDs { + for _, m := range milestones { + table.AddRow(m.ID, m.Title, m.Description, m.State, utils.FormatDueDate(m.DueDate)) + } + } else { + for _, m := range milestones { + table.AddRow(m.Title, m.Description, m.State, utils.FormatDueDate(m.DueDate)) + } + } + + o.io.LogInfo(table.String()) + return nil + } else if o.groupID != "" { // list group milestones + listMilestonesOptions := &gitlab.ListGroupMilestonesOptions{ + ListOptions: gitlab.ListOptions{ + Page: o.page, + PerPage: o.perPage, + }, + } + + if o.title != "" { + listMilestonesOptions.Title = &o.title + } + if o.search != "" { + listMilestonesOptions.Search = &o.search + } + if o.state != "" { + listMilestonesOptions.State = &o.state + } + if cmd.Flags().Changed("include-ancestors") { + listMilestonesOptions.IncludeAncestors = &o.includeAncestors + } + + milestones, _, err := client.GroupMilestones.ListGroupMilestones(o.groupID, listMilestonesOptions) + if err != nil { + return err + } + + if len(milestones) == 0 { + o.io.LogInfo("No milestones found.") + return nil + } + + if o.showIDs { + for _, m := range milestones { + table.AddRow(m.ID, m.Title, m.Description, m.State, utils.FormatDueDate(m.DueDate)) + } + } else { + for _, m := range milestones { + table.AddRow(m.Title, m.Description, m.State, utils.FormatDueDate(m.DueDate)) + } + } + + o.io.LogInfo(table.String()) + return nil + } + + return nil +} diff --git a/internal/commands/milestone/list/list_test.go b/internal/commands/milestone/list/list_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7d18c542bbcefa6f257dd959ee7ea788288ec92e --- /dev/null +++ b/internal/commands/milestone/list/list_test.go @@ -0,0 +1,164 @@ +package list + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gitlab "gitlab.com/gitlab-org/api/client-go" + gitlabtesting "gitlab.com/gitlab-org/api/client-go/testing" + "gitlab.com/gitlab-org/cli/internal/api" + "gitlab.com/gitlab-org/cli/internal/testing/cmdtest" + "go.uber.org/mock/gomock" +) + +func Test_ListProjectMilestones(t *testing.T) { + type testCase struct { + Name string + ExpectedMsg []string + wantErr bool + cli string + wantStderr string + setupMock func(tc *gitlabtesting.TestClient) + } + + testMilestone := &gitlab.Milestone{ + ID: 123, + ProjectID: 456, + Title: "Milestone title", + Description: "Example description", + State: "closed", + DueDate: gitlab.Ptr(gitlab.ISOTime(time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC))), + } + + testCases := []testCase{ + { + Name: "List project milestones", + ExpectedMsg: []string{"Title\tDescription\tState\tDue Date\nMilestone title\tExample description\tclosed\t2025-01-15\n\n"}, + cli: "--project 456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockMilestones.EXPECT().ListMilestones("456", gomock.Any()).Return([]*gitlab.Milestone{testMilestone}, nil, nil) + }, + }, + { + Name: "When --show-id is used shows a list of milestones with IDs", + ExpectedMsg: []string{"ID\tTitle\tDescription\tState\tDue Date\n123\tMilestone title\tExample description\tclosed\t2025-01-15\n\n"}, + cli: "--project 456 --show-id", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockMilestones.EXPECT().ListMilestones("456", gomock.Any()).Return([]*gitlab.Milestone{testMilestone}, nil, nil) + }, + }, + { + Name: "When no milestones are found returns a message", + ExpectedMsg: []string{"No milestones found.\n"}, + cli: "--project 456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockMilestones.EXPECT().ListMilestones("456", gomock.Any()).Return([]*gitlab.Milestone{}, nil, nil) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // GIVEN + testClient := gitlabtesting.NewTestClient(t) + tc.setupMock(testClient) + exec := cmdtest.SetupCmdForTest( + t, + NewCmdList, + false, + cmdtest.WithApiClient(cmdtest.NewTestApiClient(t, nil, "", "", api.WithGitLabClient(testClient.Client))), + ) + + // WHEN + out, err := exec(tc.cli) + + // THEN + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantStderr) + return + } + require.NoError(t, err) + for _, msg := range tc.ExpectedMsg { + assert.Equal(t, msg, out.OutBuf.String()) + } + }) + } +} + +func Test_ListGroupMilestones(t *testing.T) { + type testCase struct { + Name string + ExpectedMsg []string + wantErr bool + cli string + wantStderr string + setupMock func(tc *gitlabtesting.TestClient) + } + + testMilestone := &gitlab.GroupMilestone{ + ID: 123, + GroupID: 456, + Title: "Milestone title", + Description: "Example description", + State: "closed", + DueDate: gitlab.Ptr(gitlab.ISOTime(time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC))), + } + + testCases := []testCase{ + { + Name: "List group milestones", + ExpectedMsg: []string{"Title\tDescription\tState\tDue Date\nMilestone title\tExample description\tclosed\t2025-01-15\n\n"}, + cli: "--group 456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockGroupMilestones.EXPECT().ListGroupMilestones("456", gomock.Any()).Return([]*gitlab.GroupMilestone{testMilestone}, nil, nil) + }, + }, + { + Name: "When --show-id is used shows a list of milestones with IDs", + ExpectedMsg: []string{"ID\tTitle\tDescription\tState\tDue Date\n123\tMilestone title\tExample description\tclosed\t2025-01-15\n\n"}, + cli: "--group 456 --show-id", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockGroupMilestones.EXPECT().ListGroupMilestones("456", gomock.Any()).Return([]*gitlab.GroupMilestone{testMilestone}, nil, nil) + }, + }, + { + Name: "When no milestones are found returns a message", + ExpectedMsg: []string{"No milestones found.\n"}, + cli: "--group 456", + setupMock: func(tc *gitlabtesting.TestClient) { + tc.MockGroupMilestones.EXPECT().ListGroupMilestones("456", gomock.Any()).Return([]*gitlab.GroupMilestone{}, nil, nil) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + // GIVEN + testClient := gitlabtesting.NewTestClient(t) + tc.setupMock(testClient) + exec := cmdtest.SetupCmdForTest( + t, + NewCmdList, + false, + cmdtest.WithApiClient(cmdtest.NewTestApiClient(t, nil, "", "", api.WithGitLabClient(testClient.Client))), + ) + + // WHEN + out, err := exec(tc.cli) + + // THEN + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantStderr) + return + } + require.NoError(t, err) + for _, msg := range tc.ExpectedMsg { + assert.Equal(t, msg, out.OutBuf.String()) + } + }) + } +} diff --git a/internal/commands/milestone/milestone.go b/internal/commands/milestone/milestone.go new file mode 100644 index 0000000000000000000000000000000000000000..1e2ae78d82e9f940d2d4488cd3bac49b4b186110 --- /dev/null +++ b/internal/commands/milestone/milestone.go @@ -0,0 +1,25 @@ +package milestone + +import ( + "github.com/spf13/cobra" + "gitlab.com/gitlab-org/cli/internal/cmdutils" + cmdCreate "gitlab.com/gitlab-org/cli/internal/commands/milestone/create" + cmdGet "gitlab.com/gitlab-org/cli/internal/commands/milestone/get" + cmdList "gitlab.com/gitlab-org/cli/internal/commands/milestone/list" +) + +func NewCmdMilestone(f cmdutils.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "milestone ", + Short: "Manage group or project milestones.", + Long: "", + } + + cmdutils.EnableRepoOverride(cmd, f) + + cmd.AddCommand(cmdList.NewCmdList(f)) + cmd.AddCommand(cmdGet.NewCmdGet(f)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f)) + + return cmd +} diff --git a/internal/commands/milestone/milestone_test.go b/internal/commands/milestone/milestone_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2c4284a839b24da288f81d769072b5421a11afca --- /dev/null +++ b/internal/commands/milestone/milestone_test.go @@ -0,0 +1,32 @@ +package milestone + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "gitlab.com/gitlab-org/cli/internal/testing/cmdtest" +) + +func TestNewCmdMilestone(t *testing.T) { + ios, _, _, _ := cmdtest.TestIOStreams() + factory := cmdtest.NewTestFactory(ios) + + cmd := NewCmdMilestone(factory) + + assert.NotNil(t, cmd) + assert.Equal(t, "milestone ", cmd.Use) + assert.Equal(t, "Manage group or project milestones.", cmd.Short) + assert.True(t, cmd.HasSubCommands()) + + // Check that all expected subcommands are present + subcommands := cmd.Commands() + subcommandNames := make([]string, len(subcommands)) + for i, subcmd := range subcommands { + subcommandNames[i] = subcmd.Use + } + + expectedSubcommands := []string{"get", "list", "create"} + for _, expected := range expectedSubcommands { + assert.Contains(t, subcommandNames, expected) + } +} diff --git a/internal/commands/root.go b/internal/commands/root.go index 255de9b48c5a5836cfefa752246abd2974567358..e27737344708fabb168bcb021d77ff785234d315 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -25,6 +25,7 @@ import ( jobCmd "gitlab.com/gitlab-org/cli/internal/commands/job" labelCmd "gitlab.com/gitlab-org/cli/internal/commands/label" mcpCmd "gitlab.com/gitlab-org/cli/internal/commands/mcp" + milestoneCmd "gitlab.com/gitlab-org/cli/internal/commands/milestone" mrCmd "gitlab.com/gitlab-org/cli/internal/commands/mr" opentofuCmd "gitlab.com/gitlab-org/cli/internal/commands/opentofu" projectCmd "gitlab.com/gitlab-org/cli/internal/commands/project" @@ -130,31 +131,32 @@ func NewCmdRoot(f cmdutils.Factory) *cobra.Command { rootCmd.AddCommand(updateCmd.NewCheckUpdateCmd(f)) rootCmd.AddCommand(authCmd.NewCmdAuth(f)) + rootCmd.AddCommand(apiCmd.NewCmdApi(f, nil)) rootCmd.AddCommand(changelogCmd.NewCmdChangelog(f)) rootCmd.AddCommand(clusterCmd.NewCmdCluster(f)) + rootCmd.AddCommand(deployKeyCmd.NewCmdDeployKey(f)) + rootCmd.AddCommand(duoCmd.NewCmdDuo(f)) + rootCmd.AddCommand(gpgCmd.NewCmdGPGKey(f)) + rootCmd.AddCommand(incidentCmd.NewCmdIncident(f)) rootCmd.AddCommand(issueCmd.NewCmdIssue(f)) rootCmd.AddCommand(iterationCmd.NewCmdIteration(f)) - rootCmd.AddCommand(incidentCmd.NewCmdIncident(f)) rootCmd.AddCommand(jobCmd.NewCmdJob(f)) rootCmd.AddCommand(labelCmd.NewCmdLabel(f)) + rootCmd.AddCommand(mcpCmd.NewCmdMCP(f)) + rootCmd.AddCommand(milestoneCmd.NewCmdMilestone(f)) rootCmd.AddCommand(mrCmd.NewCmdMR(f)) + rootCmd.AddCommand(opentofuCmd.NewCmd(f)) rootCmd.AddCommand(pipelineCmd.NewCmdCI(f)) rootCmd.AddCommand(projectCmd.NewCmdRepo(f)) rootCmd.AddCommand(releaseCmd.NewCmdRelease(f)) - rootCmd.AddCommand(sshCmd.NewCmdSSHKey(f)) - rootCmd.AddCommand(gpgCmd.NewCmdGPGKey(f)) - rootCmd.AddCommand(userCmd.NewCmdUser(f)) - rootCmd.AddCommand(variableCmd.NewVariableCmd(f)) - rootCmd.AddCommand(apiCmd.NewCmdApi(f, nil)) rootCmd.AddCommand(scheduleCmd.NewCmdSchedule(f)) rootCmd.AddCommand(securefileCmd.NewCmdSecurefile(f)) rootCmd.AddCommand(snippetCmd.NewCmdSnippet(f)) - rootCmd.AddCommand(duoCmd.NewCmdDuo(f)) - rootCmd.AddCommand(mcpCmd.NewCmdMCP(f)) - rootCmd.AddCommand(tokenCmd.NewTokenCmd(f)) + rootCmd.AddCommand(sshCmd.NewCmdSSHKey(f)) rootCmd.AddCommand(stackCmd.NewCmdStack(f)) - rootCmd.AddCommand(deployKeyCmd.NewCmdDeployKey(f)) - rootCmd.AddCommand(opentofuCmd.NewCmd(f)) + rootCmd.AddCommand(tokenCmd.NewTokenCmd(f)) + rootCmd.AddCommand(userCmd.NewCmdUser(f)) + rootCmd.AddCommand(variableCmd.NewVariableCmd(f)) // TODO: This can probably be removed by GitLab 18.3 // See: https://gitlab.com/gitlab-org/cli/-/issues/7885 diff --git a/internal/commands/ssh-key/list/list_test.go b/internal/commands/ssh-key/list/list_test.go index 89f32493d99b4a5d2675c0f9b60098c13f331f4d..d1c9e1080268694db3b007f21fb5e13b331f14d0 100644 --- a/internal/commands/ssh-key/list/list_test.go +++ b/internal/commands/ssh-key/list/list_test.go @@ -81,7 +81,7 @@ func Test_ListSSHKey(t *testing.T) { } require.NoError(t, err) for _, msg := range tc.ExpectedMsg { - assert.Equal(t, out.OutBuf.String(), msg) + assert.Equal(t, msg, out.OutBuf.String()) } }) } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 469658aea63579e86f23b5cfa478e12fe0558a57..88236df976729a865a745269a741ac2ee5e43277 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -11,6 +11,7 @@ import ( "time" "github.com/charmbracelet/glamour" + gitlab "gitlab.com/gitlab-org/api/client-go" "gitlab.com/gitlab-org/cli/internal/browser" "gitlab.com/gitlab-org/cli/internal/run" ) @@ -231,3 +232,11 @@ func IsEnvVarEnabled(key string) (bool, bool) { func PrintDeprecationWarning(key string) { fmt.Fprintf(os.Stdout, "DEPRECATION WARNING: The environment variable %s has been deprecated and will be removed in future releases. Use GLAB_%s instead.\n", key, key) } + +// FormatDueDate returns an empty string if date is nil +func FormatDueDate(date *gitlab.ISOTime) string { + if date == nil { + return "" + } + return date.String() +}