feat(incident): add incident unsubscribe command

- add `incident unsubscribe` command to unsubscribe from an incident

Delivers: #1325
This commit is contained in:
Vitali Tatarintev 2023-05-04 14:42:05 +02:00
parent 945a712025
commit b7f4c3f9ef
19 changed files with 360 additions and 106 deletions

6
api/errors.go Normal file
View File

@ -0,0 +1,6 @@
package api
import "errors"
// ErrIssuableUserNotSubscribed received when trying to unsubscribe from issue the user not subscribed to
var ErrIssuableUserNotSubscribed = errors.New("you are not subscribed to this issue")

View File

@ -172,7 +172,7 @@ var UnsubscribeFromIssue = func(client *gitlab.Client, projectID interface{}, is
if resp != nil {
// If the user is not subscribed to the issue, the status code 304 is returned.
if resp.StatusCode == http.StatusNotModified {
return nil, errors.New("you are not subscribed to this issue")
return nil, ErrIssuableUserNotSubscribed
}
}
return issue, err

View File

@ -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"
incidentUnsubscribeCmd "gitlab.com/gitlab-org/cli/commands/incident/unsubscribe"
incidentViewCmd "gitlab.com/gitlab-org/cli/commands/incident/view"
"github.com/spf13/cobra"
@ -35,5 +36,6 @@ func NewCmdIncident(f *cmdutils.Factory) *cobra.Command {
incidentCmd.AddCommand(incidentViewCmd.NewCmdView(f))
incidentCmd.AddCommand(incidentCloseCmd.NewCmdClose(f))
incidentCmd.AddCommand(incidentReopenCmd.NewCmdReopen(f))
incidentCmd.AddCommand(incidentUnsubscribeCmd.NewCmdUnsubscribe(f))
return incidentCmd
}

View File

@ -0,0 +1,14 @@
package unsubscribe
import (
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/commands/issuable"
issuableUnsubscribeCmd "gitlab.com/gitlab-org/cli/commands/issuable/unsubscribe"
"github.com/spf13/cobra"
)
func NewCmdUnsubscribe(f *cmdutils.Factory) *cobra.Command {
return issuableUnsubscribeCmd.NewCmdUnsubscribe(f, issuable.TypeIncident)
}

View File

@ -157,7 +157,7 @@ func TestIssuableClose(t *testing.T) {
iid: 1,
name: "issue_close_using_incident_command",
issueType: issuable.TypeIncident,
wantOutput: "Incident not found, but an issue with the provided ID exists. Run `glab issue close <id>` to close it.\n",
wantOutput: "Incident not found, but an issue with the provided ID exists. Run `glab issue close <id>` to close.\n",
},
{
iid: 404,

View File

@ -23,7 +23,7 @@ const (
func ValidateIncidentCmd(cmd IssueType, subcmd string, issue *gitlab.Issue) (bool, string) {
if cmd == TypeIncident && *issue.IssueType != string(TypeIncident) {
return false, fmt.Sprintf(
"Incident not found, but an issue with the provided ID exists. Run `glab issue %[1]s <id>` to %[1]s it.",
"Incident not found, but an issue with the provided ID exists. Run `glab issue %[1]s <id>` to %[1]s.",
subcmd,
)
}

View File

@ -64,7 +64,7 @@ func TestValidateIncidentCmd(t *testing.T) {
if !valid {
assert.Equal(
t,
fmt.Sprintf("Incident not found, but an issue with the provided ID exists. Run `glab issue %[1]s <id>` to %[1]s it.", tt.subcmd),
fmt.Sprintf("Incident not found, but an issue with the provided ID exists. Run `glab issue %[1]s <id>` to %[1]s.", tt.subcmd),
msg,
)
}

View File

@ -157,7 +157,7 @@ func TestIssuableReopen(t *testing.T) {
iid: 1,
name: "issue_reopen_using_incident_command",
issueType: issuable.TypeIncident,
wantOutput: "Incident not found, but an issue with the provided ID exists. Run `glab issue reopen <id>` to reopen it.\n",
wantOutput: "Incident not found, but an issue with the provided ID exists. Run `glab issue reopen <id>` to reopen.\n",
},
{
iid: 404,

View File

@ -1,27 +1,40 @@
package unsubscribe
import (
"errors"
"fmt"
"github.com/MakeNowJust/heredoc"
"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"
"github.com/spf13/cobra"
)
func NewCmdUnsubscribe(f *cmdutils.Factory) *cobra.Command {
var unsubscribingMessage = map[issuable.IssueType]string{
issuable.TypeIssue: "Unsubscribing from Issue",
issuable.TypeIncident: "Unsubscribing from Incident",
}
func NewCmdUnsubscribe(f *cmdutils.Factory, issueType issuable.IssueType) *cobra.Command {
examplePath := "issues/123"
if issueType == issuable.TypeIncident {
examplePath = "issues/incident/123"
}
issueUnsubscribeCmd := &cobra.Command{
Use: "unsubscribe <id>",
Short: `Unsubscribe to an issue`,
Short: fmt.Sprintf(`Unsubscribe from an %s`, issueType),
Long: ``,
Aliases: []string{"unsub"},
Example: heredoc.Doc(`
glab issue unsubscribe 123
glab issue unsub 123
glab issue unsubscribe https://gitlab.com/profclems/glab/-/issues/123
`),
Example: heredoc.Doc(fmt.Sprintf(`
glab %[1]s unsubscribe 123
glab %[1]s unsub 123
glab %[1]s unsubscribe 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()
@ -36,16 +49,37 @@ func NewCmdUnsubscribe(f *cmdutils.Factory) *cobra.Command {
}
for _, issue := range issues {
valid, msg := issuable.ValidateIncidentCmd(issueType, "unsubscribe", issue)
if !valid {
fmt.Fprintln(f.IO.StdOut, msg)
continue
}
if f.IO.IsaTTY && f.IO.IsErrTTY {
fmt.Fprintf(f.IO.StdErr, "- Unsubscribing from Issue #%d in %s\n", issue.IID, c.Cyan(repo.FullName()))
fmt.Fprintf(
f.IO.StdOut,
"- %s #%d in %s\n",
unsubscribingMessage[issueType],
issue.IID,
c.Cyan(repo.FullName()),
)
}
issue, err := api.UnsubscribeFromIssue(apiClient, repo.FullName(), issue.IID, nil)
if err != nil {
if errors.Is(err, api.ErrIssuableUserNotSubscribed) {
fmt.Fprintf(
f.IO.StdOut,
"%s You are not subscribed to this %s\n\n",
c.FailedIcon(),
issueType,
)
return nil // the error already handled
}
return err
}
fmt.Fprintln(f.IO.StdErr, c.RedCheck(), "Unsubscribed")
fmt.Fprintln(f.IO.StdOut, c.GreenCheck(), "Unsubscribed")
fmt.Fprintln(f.IO.StdOut, issueutils.DisplayIssue(c, issue, f.IO.IsaTTY))
}
return nil

View File

@ -1,87 +0,0 @@
package unsubscribe
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 TestNewCmdUnsubscribe_Integration(t *testing.T) {
glTestHost := test.GetHostOrSkip(t)
t.Parallel()
oldUnsubscribeIssue := api.UnsubscribeFromIssue
timer, _ := time.Parse(time.RFC3339, "2014-11-12T11:45:26.371Z")
api.UnsubscribeFromIssue = 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: "- Unsubscribing from Issue #1 in cli-automated-testing/test\n✓ Unsubscribed\n",
},
{
Name: "Issue Does Not Exist",
Issue: "0",
stderr: "- Unsubscribing from 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 := NewCmdUnsubscribe(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.UnsubscribeFromIssue = oldUnsubscribeIssue
}

View File

@ -0,0 +1,207 @@
package unsubscribe
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 := NewCmdUnsubscribe(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 mockIssuableUnsubscribe(fakeHTTP *httpmock.Mocker, id int, issueType string, subscribed bool) {
fakeHTTP.RegisterResponder(http.MethodPost, fmt.Sprintf("/projects/OWNER/REPO/issues/%d/unsubscribe", 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 TestIssuableUnsubscribe(t *testing.T) {
t.Run("issue_unsubscribe", func(t *testing.T) {
iid := 1
fakeHTTP := httpmock.New()
mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIssue), true)
mockIssuableUnsubscribe(fakeHTTP, iid, string(issuable.TypeIssue), false)
output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIssue)
wantOutput := heredoc.Doc(`
- Unsubscribing from Issue #1 in OWNER/REPO
Unsubscribed
`)
require.NoErrorf(t, err, "error running command `issue unsubscribe %d`", iid)
require.Contains(t, output.String(), wantOutput)
require.Empty(t, output.Stderr())
})
t.Run("incident_unsubscribe", func(t *testing.T) {
iid := 2
fakeHTTP := httpmock.New()
mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIncident), true)
mockIssuableUnsubscribe(fakeHTTP, iid, string(issuable.TypeIncident), false)
output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIncident)
wantOutput := heredoc.Doc(`
- Unsubscribing from Incident #2 in OWNER/REPO
Unsubscribed
`)
require.NoErrorf(t, err, "error running command `incident unsubscribe %d`", iid)
require.Contains(t, output.String(), wantOutput)
require.Empty(t, output.Stderr())
})
t.Run("incident_unsubscribe_using_issue_command", func(t *testing.T) {
iid := 2
fakeHTTP := httpmock.New()
mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIncident), true)
mockIssuableUnsubscribe(fakeHTTP, iid, string(issuable.TypeIncident), false)
output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIssue)
wantOutput := heredoc.Doc(`
- Unsubscribing from Issue #2 in OWNER/REPO
Unsubscribed
`)
require.NoErrorf(t, err, "error running command `issue unsubscribe %d`", iid)
require.Contains(t, output.String(), wantOutput)
require.Empty(t, output.Stderr())
})
t.Run("issue_unsubscribe_using_incident_command", func(t *testing.T) {
iid := 1
fakeHTTP := httpmock.New()
mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIssue), true)
mockIssuableUnsubscribe(fakeHTTP, iid, string(issuable.TypeIssue), false)
output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIncident)
wantOutput := "Incident not found, but an issue with the provided ID exists. Run `glab issue unsubscribe <id>` to unsubscribe.\n"
require.NoErrorf(t, err, "error running command `incident unsubscribe %d`", iid)
require.Contains(t, output.String(), wantOutput)
require.Empty(t, output.Stderr())
})
t.Run("issue_unsubscribe_from_non_subscribed_issue", func(t *testing.T) {
iid := 3
fakeHTTP := httpmock.New()
mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIssue), false)
fakeHTTP.RegisterResponder(http.MethodPost, "/projects/OWNER/REPO/issues/3/unsubscribe",
httpmock.NewStringResponse(http.StatusNotModified, ``),
)
output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIssue)
wantOutput := heredoc.Doc(`
- Unsubscribing from Issue #3 in OWNER/REPO
x You are not subscribed to this issue
`)
require.NoErrorf(t, err, "error running command `issue unsubscribe %d`", iid)
require.Contains(t, output.String(), wantOutput)
require.Empty(t, output.Stderr())
})
t.Run("incident_unsubscribe_from_non_subscribed_incident", func(t *testing.T) {
iid := 3
fakeHTTP := httpmock.New()
mockIssuableGet(fakeHTTP, iid, string(issuable.TypeIncident), false)
fakeHTTP.RegisterResponder(http.MethodPost, "/projects/OWNER/REPO/issues/3/unsubscribe",
httpmock.NewStringResponse(http.StatusNotModified, ``),
)
output, err := runCommand(fakeHTTP, fmt.Sprint(iid), issuable.TypeIncident)
wantOutput := heredoc.Doc(`
- Unsubscribing from Incident #3 in OWNER/REPO
x You are not subscribed to this incident
`)
require.NoErrorf(t, err, "error running command `incident unsubscribe %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")
})
}

View File

@ -220,7 +220,7 @@ func TestNewCmdView(t *testing.T) {
stderr.Reset()
viewIncidentWithIssueID := tt.viewIssueType == issuable.TypeIncident && testIssuable.issueType != issuable.TypeIncident
wantErrorMsg := "Incident not found, but an issue with the provided ID exists. Run `glab issue view <id>` to view it.\n"
wantErrorMsg := "Incident not found, but an issue with the provided ID exists. Run `glab issue view <id>` to view.\n"
if tt.isTTY {
if viewIncidentWithIssueID {

View File

@ -3,7 +3,6 @@ package issue
import (
"github.com/MakeNowJust/heredoc"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
issuableUnsubscribeCmd "gitlab.com/gitlab-org/cli/commands/issuable/unsubscribe"
issueBoardCmd "gitlab.com/gitlab-org/cli/commands/issue/board"
issueCloseCmd "gitlab.com/gitlab-org/cli/commands/issue/close"
issueCreateCmd "gitlab.com/gitlab-org/cli/commands/issue/create"
@ -12,6 +11,7 @@ import (
issueNoteCmd "gitlab.com/gitlab-org/cli/commands/issue/note"
issueReopenCmd "gitlab.com/gitlab-org/cli/commands/issue/reopen"
issueSubscribeCmd "gitlab.com/gitlab-org/cli/commands/issue/subscribe"
issueUnsubscribeCmd "gitlab.com/gitlab-org/cli/commands/issue/unsubscribe"
issueUpdateCmd "gitlab.com/gitlab-org/cli/commands/issue/update"
issueViewCmd "gitlab.com/gitlab-org/cli/commands/issue/view"
@ -49,7 +49,7 @@ func NewCmdIssue(f *cmdutils.Factory) *cobra.Command {
issueCmd.AddCommand(issueReopenCmd.NewCmdReopen(f))
issueCmd.AddCommand(issueViewCmd.NewCmdView(f))
issueCmd.AddCommand(issueSubscribeCmd.NewCmdSubscribe(f))
issueCmd.AddCommand(issuableUnsubscribeCmd.NewCmdUnsubscribe(f))
issueCmd.AddCommand(issueUnsubscribeCmd.NewCmdUnsubscribe(f))
issueCmd.AddCommand(issueUpdateCmd.NewCmdUpdate(f))
return issueCmd
}

View File

@ -0,0 +1,14 @@
package unsubscribe
import (
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/commands/issuable"
issuableUnsubscribeCmd "gitlab.com/gitlab-org/cli/commands/issuable/unsubscribe"
"github.com/spf13/cobra"
)
func NewCmdUnsubscribe(f *cmdutils.Factory) *cobra.Command {
return issuableUnsubscribeCmd.NewCmdUnsubscribe(f, issuable.TypeIssue)
}

View File

@ -37,4 +37,5 @@ glab incident list
- [close](close.md)
- [list](list.md)
- [reopen](reopen.md)
- [unsubscribe](unsubscribe.md)
- [view](view.md)

View File

@ -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
---
<!--
This documentation is auto generated by a script.
Please do not edit this file directly. Run `make gen-docs` instead.
-->
# `glab incident unsubscribe`
Unsubscribe from an incident
```plaintext
glab incident unsubscribe <id> [flags]
```
## Aliases
```plaintext
unsub
```
## Examples
```plaintext
glab incident unsubscribe 123
glab incident unsub 123
glab incident unsubscribe 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
```

View File

@ -11,7 +11,7 @@ Please do not edit this file directly. Run `make gen-docs` instead.
# `glab issue unsubscribe`
Unsubscribe to an issue
Unsubscribe from an issue
```plaintext
glab issue unsubscribe <id> [flags]
@ -28,7 +28,7 @@ unsub
```plaintext
glab issue unsubscribe 123
glab issue unsub 123
glab issue unsubscribe https://gitlab.com/profclems/glab/-/issues/123
glab issue unsubscribe https://gitlab.com/OWNER/REPO/-/issues/123
```

View File

@ -215,3 +215,8 @@ func Map[T1, T2 any](elems []T1, fn func(T1) T2) []T2 {
return r
}
// Ptr takes any value and returns a pointer to that value
func Ptr[T any](v T) *T {
return &v
}

View File

@ -6,6 +6,7 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_PrettyTimeAgo(t *testing.T) {
@ -219,3 +220,20 @@ func TestIsValidURL(t *testing.T) {
})
}
}
func TestPtr(t *testing.T) {
tests := []struct {
name string
val any
}{
{"string", "GitLab"},
{"int", 503},
{"float", 50.3},
{"time", time.Now()},
{"struct", struct{}{}},
}
for _, tt := range tests {
require.Equal(t, Ptr(tt.val), &tt.val)
}
}