diff --git a/api/errors.go b/api/errors.go index a5872606..13217f63 100644 --- a/api/errors.go +++ b/api/errors.go @@ -2,5 +2,8 @@ package api import "errors" -// ErrIssuableUserNotSubscribed received when trying to unsubscribe from issue the user not subscribed to +// ErrIssuableUserNotSubscribed received when trying to unsubscribe from an issue the user is not subscribed to var ErrIssuableUserNotSubscribed = errors.New("you are not subscribed to this issue") + +// ErrIssuableUserAlreadySubscribed received when trying to subscribe to an issue the user is already subscribed to +var ErrIssuableUserAlreadySubscribed = errors.New("you are already subscribed to this issue") diff --git a/api/issue.go b/api/issue.go index 310ee56c..047a86b4 100644 --- a/api/issue.go +++ b/api/issue.go @@ -2,7 +2,6 @@ package api import ( - "errors" "net/http" "github.com/xanzy/go-gitlab" @@ -153,7 +152,7 @@ var SubscribeToIssue = func(client *gitlab.Client, projectID interface{}, issueI if resp != nil { // If the user is already subscribed to the issue, the status code 304 is returned. if resp.StatusCode == http.StatusNotModified { - return nil, errors.New("you are already subscribed to this issue") + return nil, ErrIssuableUserAlreadySubscribed } } return issue, err diff --git a/commands/incident/incident.go b/commands/incident/incident.go index aa669770..102d1921 100644 --- a/commands/incident/incident.go +++ b/commands/incident/incident.go @@ -7,6 +7,7 @@ import ( incidentCloseCmd "gitlab.com/gitlab-org/cli/commands/incident/close" incidentListCmd "gitlab.com/gitlab-org/cli/commands/incident/list" incidentReopenCmd "gitlab.com/gitlab-org/cli/commands/incident/reopen" + incidentSubscribeCmd "gitlab.com/gitlab-org/cli/commands/incident/subscribe" incidentUnsubscribeCmd "gitlab.com/gitlab-org/cli/commands/incident/unsubscribe" incidentViewCmd "gitlab.com/gitlab-org/cli/commands/incident/view" @@ -36,6 +37,7 @@ func NewCmdIncident(f *cmdutils.Factory) *cobra.Command { incidentCmd.AddCommand(incidentViewCmd.NewCmdView(f)) incidentCmd.AddCommand(incidentCloseCmd.NewCmdClose(f)) incidentCmd.AddCommand(incidentReopenCmd.NewCmdReopen(f)) + incidentCmd.AddCommand(incidentSubscribeCmd.NewCmdSubscribe(f)) incidentCmd.AddCommand(incidentUnsubscribeCmd.NewCmdUnsubscribe(f)) return incidentCmd } diff --git a/commands/incident/subscribe/incident_subscribe.go b/commands/incident/subscribe/incident_subscribe.go new file mode 100644 index 00000000..cd8e78e4 --- /dev/null +++ b/commands/incident/subscribe/incident_subscribe.go @@ -0,0 +1,13 @@ +package subscribe + +import ( + "github.com/spf13/cobra" + "gitlab.com/gitlab-org/cli/commands/cmdutils" + "gitlab.com/gitlab-org/cli/commands/issuable" + + issuableSubscribeCmd "gitlab.com/gitlab-org/cli/commands/issuable/subscribe" +) + +func NewCmdSubscribe(f *cmdutils.Factory) *cobra.Command { + return issuableSubscribeCmd.NewCmdSubscribe(f, issuable.TypeIncident) +} diff --git a/commands/issuable/subscribe/issuable_subscribe.go b/commands/issuable/subscribe/issuable_subscribe.go new file mode 100644 index 00000000..9a889b9f --- /dev/null +++ b/commands/issuable/subscribe/issuable_subscribe.go @@ -0,0 +1,89 @@ +package subscribe + +import ( + "errors" + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + "gitlab.com/gitlab-org/cli/api" + "gitlab.com/gitlab-org/cli/commands/cmdutils" + "gitlab.com/gitlab-org/cli/commands/issuable" + "gitlab.com/gitlab-org/cli/commands/issue/issueutils" +) + +var subscribingMessage = map[issuable.IssueType]string{ + issuable.TypeIssue: "Subscribing to Issue", + issuable.TypeIncident: "Subscribing to Incident", +} + +func NewCmdSubscribe(f *cmdutils.Factory, issueType issuable.IssueType) *cobra.Command { + examplePath := "issues/123" + + if issueType == issuable.TypeIncident { + examplePath = "issues/incident/123" + } + + issueSubscribeCmd := &cobra.Command{ + Use: "subscribe ", + Short: fmt.Sprintf(`Subscribe to an %s`, issueType), + Long: ``, + Aliases: []string{"sub"}, + Example: heredoc.Doc(fmt.Sprintf(` + glab %[1]s subscribe 123 + glab %[1]s sub 123 + glab %[1]s subscribe https://gitlab.com/OWNER/REPO/-/%[2]s + `, issueType, examplePath)), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := f.IO.Color() + apiClient, err := f.HttpClient() + if err != nil { + return err + } + + issues, repo, err := issueutils.IssuesFromArgs(apiClient, f.BaseRepo, args) + if err != nil { + return err + } + + for _, issue := range issues { + valid, msg := issuable.ValidateIncidentCmd(issueType, "subscribe", issue) + if !valid { + fmt.Fprintln(f.IO.StdOut, msg) + continue + } + + if f.IO.IsaTTY && f.IO.IsErrTTY { + fmt.Fprintf( + f.IO.StdOut, + "- %s #%d in %s\n", + subscribingMessage[issueType], + issue.IID, + c.Cyan(repo.FullName()), + ) + } + + issue, err := api.SubscribeToIssue(apiClient, repo.FullName(), issue.IID, nil) + if err != nil { + if errors.Is(err, api.ErrIssuableUserAlreadySubscribed) { + fmt.Fprintf( + f.IO.StdOut, + "%s You are already subscribed to this %s\n\n", + c.FailedIcon(), + issueType, + ) + return nil // the error already handled + } + return err + } + + fmt.Fprintln(f.IO.StdOut, c.GreenCheck(), "Subscribed") + fmt.Fprintln(f.IO.StdOut, issueutils.DisplayIssue(c, issue, f.IO.IsaTTY)) + } + return nil + }, + } + + return issueSubscribeCmd +} diff --git a/commands/issuable/subscribe/issuable_subscribe_test.go b/commands/issuable/subscribe/issuable_subscribe_test.go new file mode 100644 index 00000000..5d919903 --- /dev/null +++ b/commands/issuable/subscribe/issuable_subscribe_test.go @@ -0,0 +1,207 @@ +package subscribe + +import ( + "fmt" + "net/http" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/google/shlex" + "github.com/stretchr/testify/require" + "github.com/xanzy/go-gitlab" + "gitlab.com/gitlab-org/cli/api" + "gitlab.com/gitlab-org/cli/commands/cmdutils" + "gitlab.com/gitlab-org/cli/commands/issuable" + "gitlab.com/gitlab-org/cli/internal/glrepo" + "gitlab.com/gitlab-org/cli/pkg/httpmock" + "gitlab.com/gitlab-org/cli/pkg/iostreams" + "gitlab.com/gitlab-org/cli/test" +) + +func runCommand(rt http.RoundTripper, issuableID string, issueType issuable.IssueType) (*test.CmdOut, error) { + ios, _, stdout, stderr := iostreams.Test() + ios.IsaTTY = true + ios.IsErrTTY = true + + factory := &cmdutils.Factory{ + IO: ios, + HttpClient: func() (*gitlab.Client, error) { + a, err := api.TestClient(&http.Client{Transport: rt}, "", "", false) + if err != nil { + return nil, err + } + return a.Lab(), err + }, + BaseRepo: func() (glrepo.Interface, error) { + return glrepo.New("OWNER", "REPO"), nil + }, + } + + _, _ = factory.HttpClient() + + cmd := NewCmdSubscribe(factory, issueType) + + argv, err := shlex.Split(issuableID) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func mockIssuableGet(fakeHTTP *httpmock.Mocker, id int, issueType string, subscribed bool) { + fakeHTTP.RegisterResponder(http.MethodGet, fmt.Sprintf("/projects/OWNER/REPO/issues/%d", id), + httpmock.NewStringResponse(http.StatusOK, fmt.Sprintf(`{ + "id": %d, + "iid": %d, + "title": "test issue", + "subscribed": %t, + "issue_type": "%s", + "created_at": "2023-05-02T10:51:26.371Z" + }`, id, id, subscribed, issueType)), + ) +} + +func mockIssuableSubscribe(fakeHTTP *httpmock.Mocker, id int, issueType string, subscribed bool) { + fakeHTTP.RegisterResponder(http.MethodPost, fmt.Sprintf("/projects/OWNER/REPO/issues/%d/subscribe", id), + func(req *http.Request) (*http.Response, error) { + resp, _ := httpmock.NewStringResponse(http.StatusOK, fmt.Sprintf(`{ + "id": %d, + "iid": %d, + "subscribed": %t, + "issue_type": "%s", + "created_at": "2023-05-02T10:51:26.371Z" + }`, id, id, subscribed, issueType))(req) + + return resp, nil + }, + ) +} + +func TestIssuableSubscribe(t *testing.T) { + t.Run("issue_subscribe", func(t *testing.T) { + iid := 1 + fakeHTTP := httpmock.New() + + mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIssue), false) + mockIssuableSubscribe(fakeHTTP, iid, string(issuable.TypeIssue), true) + + output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIssue) + + wantOutput := heredoc.Doc(` + - Subscribing to Issue #1 in OWNER/REPO + āœ“ Subscribed + `) + require.NoErrorf(t, err, "error running command `issue subscribe %d`", iid) + require.Contains(t, output.String(), wantOutput) + require.Empty(t, output.Stderr()) + }) + + t.Run("incident_subscribe", func(t *testing.T) { + iid := 2 + fakeHTTP := httpmock.New() + + mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIncident), false) + mockIssuableSubscribe(fakeHTTP, iid, string(issuable.TypeIncident), true) + + output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIncident) + + wantOutput := heredoc.Doc(` + - Subscribing to Incident #2 in OWNER/REPO + āœ“ Subscribed + `) + require.NoErrorf(t, err, "error running command `incident subscribe %d`", iid) + require.Contains(t, output.String(), wantOutput) + require.Empty(t, output.Stderr()) + }) + + t.Run("incident_subscribe_using_issue_command", func(t *testing.T) { + iid := 2 + fakeHTTP := httpmock.New() + + mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIncident), false) + mockIssuableSubscribe(fakeHTTP, iid, string(issuable.TypeIncident), true) + + output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIssue) + + wantOutput := heredoc.Doc(` + - Subscribing to Issue #2 in OWNER/REPO + āœ“ Subscribed + `) + require.NoErrorf(t, err, "error running command `issue subscribe %d`", iid) + require.Contains(t, output.String(), wantOutput) + require.Empty(t, output.Stderr()) + }) + + t.Run("issue_subscribe_using_incident_command", func(t *testing.T) { + iid := 1 + fakeHTTP := httpmock.New() + + mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIssue), false) + mockIssuableSubscribe(fakeHTTP, iid, string(issuable.TypeIssue), true) + + output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIncident) + + wantOutput := "Incident not found, but an issue with the provided ID exists. Run `glab issue subscribe ` to subscribe.\n" + require.NoErrorf(t, err, "error running command `incident subscribe %d`", iid) + require.Contains(t, output.String(), wantOutput) + require.Empty(t, output.Stderr()) + }) + + t.Run("issue_subscribe_to_already_subscribed_issue", func(t *testing.T) { + iid := 3 + fakeHTTP := httpmock.New() + + mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIssue), true) + fakeHTTP.RegisterResponder(http.MethodPost, "/projects/OWNER/REPO/issues/3/subscribe", + httpmock.NewStringResponse(http.StatusNotModified, ``), + ) + + output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIssue) + + wantOutput := heredoc.Doc(` + - Subscribing to Issue #3 in OWNER/REPO + x You are already subscribed to this issue + `) + require.NoErrorf(t, err, "error running command `issue subscribe %d`", iid) + require.Contains(t, output.String(), wantOutput) + require.Empty(t, output.Stderr()) + }) + + t.Run("incident_subscribe_to_already_subscribed_incident", func(t *testing.T) { + iid := 3 + fakeHTTP := httpmock.New() + + mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIncident), true) + fakeHTTP.RegisterResponder(http.MethodPost, "/projects/OWNER/REPO/issues/3/subscribe", + httpmock.NewStringResponse(http.StatusNotModified, ``), + ) + + output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIncident) + + wantOutput := heredoc.Doc(` + - Subscribing to Incident #3 in OWNER/REPO + x You are already subscribed to this incident + `) + require.NoErrorf(t, err, "error running command `incident subscribe %d`", iid) + require.Contains(t, output.String(), wantOutput) + require.Empty(t, output.Stderr()) + }) + + t.Run("issue_not_found", func(t *testing.T) { + fakeHTTP := httpmock.New() + fakeHTTP.RegisterResponder(http.MethodGet, "/projects/OWNER/REPO/issues/404", + httpmock.NewStringResponse(http.StatusNotFound, `{"message": "404 not found"}`), + ) + + iid := 404 + _, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIssue) + + require.Contains(t, err.Error(), "404 not found") + }) +} diff --git a/commands/issue/subscribe/issue_subscribe.go b/commands/issue/subscribe/issue_subscribe.go index d2f133c7..0511eabb 100644 --- a/commands/issue/subscribe/issue_subscribe.go +++ b/commands/issue/subscribe/issue_subscribe.go @@ -1,55 +1,13 @@ package subscribe import ( - "fmt" - - "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" - "gitlab.com/gitlab-org/cli/api" "gitlab.com/gitlab-org/cli/commands/cmdutils" - "gitlab.com/gitlab-org/cli/commands/issue/issueutils" + "gitlab.com/gitlab-org/cli/commands/issuable" + + issuableSubscribeCmd "gitlab.com/gitlab-org/cli/commands/issuable/subscribe" ) func NewCmdSubscribe(f *cmdutils.Factory) *cobra.Command { - issueSubscribeCmd := &cobra.Command{ - Use: "subscribe ", - Short: `Subscribe to an issue`, - Long: ``, - Aliases: []string{"sub"}, - Example: heredoc.Doc(` - glab issue subscribe 123 - glab issue sub 123 - glab issue subscribe https://gitlab.com/profclems/glab/-/issues/123 - `), - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - c := f.IO.Color() - apiClient, err := f.HttpClient() - if err != nil { - return err - } - - issues, repo, err := issueutils.IssuesFromArgs(apiClient, f.BaseRepo, args) - if err != nil { - return err - } - - for _, issue := range issues { - if f.IO.IsErrTTY && f.IO.IsaTTY { - fmt.Fprintf(f.IO.StdErr, "- Subscribing to Issue #%d in %s\n", issue.IID, c.Cyan(repo.FullName())) - } - - issue, err := api.SubscribeToIssue(apiClient, repo.FullName(), issue.IID, nil) - if err != nil { - return err - } - - fmt.Fprintln(f.IO.StdErr, c.GreenCheck(), "Subscribed") - fmt.Fprintln(f.IO.StdOut, issueutils.DisplayIssue(c, issue, f.IO.IsaTTY)) - } - return nil - }, - } - - return issueSubscribeCmd + return issuableSubscribeCmd.NewCmdSubscribe(f, issuable.TypeIssue) } diff --git a/commands/issue/subscribe/issue_subscribe_integration_test.go b/commands/issue/subscribe/issue_subscribe_integration_test.go deleted file mode 100644 index a1ca8d7e..00000000 --- a/commands/issue/subscribe/issue_subscribe_integration_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package subscribe - -import ( - "fmt" - "testing" - "time" - - "gitlab.com/gitlab-org/cli/test" - - "gitlab.com/gitlab-org/cli/pkg/iostreams" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/xanzy/go-gitlab" - "gitlab.com/gitlab-org/cli/api" - "gitlab.com/gitlab-org/cli/commands/cmdtest" -) - -func TestNewCmdSubscribe_Integration(t *testing.T) { - glTestHost := test.GetHostOrSkip(t) - - t.Parallel() - - oldSubscribeIssue := api.SubscribeToIssue - timer, _ := time.Parse(time.RFC3339, "2014-11-12T11:45:26.371Z") - api.SubscribeToIssue = func(client *gitlab.Client, projectID interface{}, issueID int, opts gitlab.RequestOptionFunc) (*gitlab.Issue, error) { - if projectID == "" || projectID == "WRONG_REPO" || projectID == "expected_err" || issueID == 0 { - return nil, fmt.Errorf("error expected") - } - return &gitlab.Issue{ - ID: issueID, - IID: issueID, - State: "closed", - Description: "Dummy description for issue " + string(rune(issueID)), - Author: &gitlab.IssueAuthor{ - ID: 1, - Name: "John Dev Wick", - Username: "jdwick", - }, - CreatedAt: &timer, - }, nil - } - - testCases := []struct { - Name string - Issue string - stderr string - wantErr bool - }{ - { - Name: "Issue Exists", - Issue: "1", - stderr: "- Subscribing to Issue #1 in cli-automated-testing/test\nāœ“ Subscribed\n", - }, - { - Name: "Issue Does Not Exist", - Issue: "0", - stderr: "- Subscribing to Issue #0 in cli-automated-testing/test\nerror expected\n", - wantErr: true, - }, - } - - for _, tc := range testCases { - t.Run(tc.Name, func(t *testing.T) { - io, _, _, stderr := iostreams.Test() - f := cmdtest.StubFactory(glTestHost + "/cli-automated-testing/test") - f.IO = io - f.IO.IsaTTY = true - f.IO.IsErrTTY = true - - cmd := NewCmdSubscribe(f) - cmd.Flags().StringP("repo", "R", "", "") - - _, err := cmdtest.RunCommand(cmd, tc.Issue) - if tc.wantErr { - require.Error(t, err) - return - } else { - require.NoError(t, err) - } - assert.Equal(t, tc.stderr, stderr.String()) - }) - } - - api.SubscribeToIssue = oldSubscribeIssue -} diff --git a/docs/source/incident/index.md b/docs/source/incident/index.md index 2d29bb53..e4ea3f21 100644 --- a/docs/source/incident/index.md +++ b/docs/source/incident/index.md @@ -37,5 +37,6 @@ glab incident list - [close](close.md) - [list](list.md) - [reopen](reopen.md) +- [subscribe](subscribe.md) - [unsubscribe](unsubscribe.md) - [view](view.md) diff --git a/docs/source/incident/subscribe.md b/docs/source/incident/subscribe.md new file mode 100644 index 00000000..bac7cfdc --- /dev/null +++ b/docs/source/incident/subscribe.md @@ -0,0 +1,40 @@ +--- +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 +--- + + + +# `glab incident subscribe` + +Subscribe to an incident + +```plaintext +glab incident subscribe [flags] +``` + +## Aliases + +```plaintext +sub +``` + +## Examples + +```plaintext +glab incident subscribe 123 +glab incident sub 123 +glab incident subscribe https://gitlab.com/OWNER/REPO/-/issues/incident/123 + +``` + +## Options inherited from parent commands + +```plaintext + --help Show help for command + -R, --repo OWNER/REPO Select another repository using the OWNER/REPO or `GROUP/NAMESPACE/REPO` format or full URL or git URL +``` diff --git a/docs/source/issue/subscribe.md b/docs/source/issue/subscribe.md index a84dd4af..c63d3d46 100644 --- a/docs/source/issue/subscribe.md +++ b/docs/source/issue/subscribe.md @@ -28,7 +28,7 @@ sub ```plaintext glab issue subscribe 123 glab issue sub 123 -glab issue subscribe https://gitlab.com/profclems/glab/-/issues/123 +glab issue subscribe https://gitlab.com/OWNER/REPO/-/issues/123 ```