-
Notifications
You must be signed in to change notification settings - Fork 7
feat(jtk): add remotelinks subcommand for issue web links #415
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
0846907
feat(jtk): add remotelinks subcommand for issue web links
piekstra a8f7661
refactor(jtk): address review feedback on remotelinks
piekstra 4d7f991
refactor(jtk): pass int linkID to PresentRemoved; note remotelink verbs
piekstra 5023be4
refactor(jtk): use delete for remotelinks
rianjs 65ba54f
test(jtk): cover remotelinks command surface
rianjs d8c2141
Merge branch 'main' into feat/jtk-issue-remotelink
rianjs File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| package api //nolint:revive // package name is intentional | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "net/url" | ||
| ) | ||
|
|
||
| // RemoteLink represents a Jira issue remote link (a "Web Link" in the Jira UI): | ||
| // an external URL attached to an issue and shown in the issue's links sidebar. | ||
| // See POST/GET /rest/api/3/issue/{issueIdOrKey}/remotelink. | ||
| type RemoteLink struct { | ||
| ID int `json:"id"` | ||
| Self string `json:"self,omitempty"` | ||
| GlobalID string `json:"globalId,omitempty"` | ||
| Relationship string `json:"relationship,omitempty"` | ||
| Application *RemoteLinkApp `json:"application,omitempty"` | ||
| Object RemoteLinkObject `json:"object"` | ||
| } | ||
|
|
||
| // RemoteLinkApp identifies the application a remote link belongs to. | ||
| type RemoteLinkApp struct { | ||
| Type string `json:"type,omitempty"` | ||
| Name string `json:"name,omitempty"` | ||
| } | ||
|
|
||
| // RemoteLinkObject holds the external resource a remote link points at. | ||
| type RemoteLinkObject struct { | ||
| URL string `json:"url"` | ||
| Title string `json:"title"` | ||
| Summary string `json:"summary,omitempty"` | ||
| } | ||
|
|
||
| // CreateRemoteLinkRequest is the body for creating/updating a remote link. | ||
| type CreateRemoteLinkRequest struct { | ||
| GlobalID string `json:"globalId,omitempty"` | ||
| Relationship string `json:"relationship,omitempty"` | ||
| Object RemoteLinkObject `json:"object"` | ||
| } | ||
|
|
||
| // remoteLinkCreateResponse is the slim response Jira returns from create: | ||
| // it identifies the link but does not echo back the full object. | ||
| type remoteLinkCreateResponse struct { | ||
| ID int `json:"id"` | ||
| Self string `json:"self"` | ||
| } | ||
|
|
||
| // GetRemoteLinks returns the remote (web) links on an issue. | ||
| func (c *Client) GetRemoteLinks(ctx context.Context, issueKey string) ([]RemoteLink, error) { | ||
| if issueKey == "" { | ||
| return nil, ErrIssueKeyRequired | ||
| } | ||
|
|
||
| urlStr := fmt.Sprintf("%s/issue/%s/remotelink", c.BaseURL, url.PathEscape(issueKey)) | ||
| body, err := c.Get(ctx, urlStr) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("fetching remote links: %w", err) | ||
| } | ||
|
|
||
| var links []RemoteLink | ||
| if err := json.Unmarshal(body, &links); err != nil { | ||
| return nil, fmt.Errorf("parsing remote links: %w", err) | ||
| } | ||
|
|
||
| return links, nil | ||
| } | ||
|
|
||
| // AddRemoteLink creates a remote (web) link on an issue pointing at url with | ||
| // the given title. Jira's create response is slim (id + self only), so the | ||
|
monit-reviewer marked this conversation as resolved.
|
||
| // returned RemoteLink echoes back the input object alongside the new ID. | ||
| func (c *Client) AddRemoteLink(ctx context.Context, issueKey string, req CreateRemoteLinkRequest) (*RemoteLink, error) { | ||
| if issueKey == "" { | ||
| return nil, ErrIssueKeyRequired | ||
| } | ||
| if req.Object.URL == "" { | ||
| return nil, ErrRemoteLinkURLRequired | ||
| } | ||
|
monit-reviewer marked this conversation as resolved.
|
||
| if req.Object.Title == "" { | ||
| return nil, ErrRemoteLinkTitleRequired | ||
| } | ||
|
|
||
| urlStr := fmt.Sprintf("%s/issue/%s/remotelink", c.BaseURL, url.PathEscape(issueKey)) | ||
| body, err := c.Post(ctx, urlStr, req) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("adding remote link: %w", err) | ||
| } | ||
|
|
||
| var resp remoteLinkCreateResponse | ||
| if err := json.Unmarshal(body, &resp); err != nil { | ||
| return nil, fmt.Errorf("parsing remote link: %w", err) | ||
| } | ||
|
|
||
| return &RemoteLink{ | ||
| ID: resp.ID, | ||
| Self: resp.Self, | ||
| GlobalID: req.GlobalID, | ||
| Relationship: req.Relationship, | ||
| Object: req.Object, | ||
| }, nil | ||
| } | ||
|
|
||
| // DeleteRemoteLink deletes a remote link from an issue by its link ID. linkID | ||
| // is an int to match the RemoteLink.ID domain type returned by AddRemoteLink | ||
| // and GetRemoteLinks, so a list-then-delete flow needs no string conversion. | ||
| func (c *Client) DeleteRemoteLink(ctx context.Context, issueKey string, linkID int) error { | ||
| if issueKey == "" { | ||
| return ErrIssueKeyRequired | ||
| } | ||
| if linkID <= 0 { | ||
| return ErrRemoteLinkIDRequired | ||
| } | ||
|
|
||
| urlStr := fmt.Sprintf("%s/issue/%s/remotelink/%d", c.BaseURL, url.PathEscape(issueKey), linkID) | ||
| if _, err := c.Delete(ctx, urlStr); err != nil { | ||
| return fmt.Errorf("deleting remote link %d: %w", linkID, err) | ||
| } | ||
| return nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| package api | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "io" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
|
|
||
| "github.com/open-cli-collective/atlassian-go/testutil" | ||
| ) | ||
|
|
||
| func TestGetRemoteLinks(t *testing.T) { | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| testutil.Equal(t, r.URL.Path, "/rest/api/3/issue/PROJ-123/remotelink") | ||
| testutil.Equal(t, r.Method, http.MethodGet) | ||
|
|
||
| _ = json.NewEncoder(w).Encode([]map[string]any{ | ||
| { | ||
| "id": 10001, | ||
| "self": "https://acme.atlassian.net/rest/api/3/issue/PROJ-123/remotelink/10001", | ||
| "relationship": "mentioned in", | ||
| "object": map[string]any{ | ||
| "url": "https://github.com/owner/repo/issues/456", | ||
| "title": "GitHub #456", | ||
| "summary": "Some issue", | ||
| }, | ||
| }, | ||
| }) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) | ||
| testutil.RequireNoError(t, err) | ||
|
|
||
| links, err := client.GetRemoteLinks(context.Background(), "PROJ-123") | ||
| testutil.RequireNoError(t, err) | ||
| testutil.Len(t, links, 1) | ||
| testutil.Equal(t, links[0].ID, 10001) | ||
| testutil.Equal(t, links[0].Object.URL, "https://github.com/owner/repo/issues/456") | ||
| testutil.Equal(t, links[0].Object.Title, "GitHub #456") | ||
| testutil.Equal(t, links[0].Relationship, "mentioned in") | ||
| } | ||
|
|
||
| func TestGetRemoteLinks_EmptyKey(t *testing.T) { | ||
| _, err := (&Client{}).GetRemoteLinks(context.Background(), "") | ||
| testutil.Equal(t, err, ErrIssueKeyRequired) | ||
| } | ||
|
|
||
| func TestAddRemoteLink(t *testing.T) { | ||
| var capturedBody []byte | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| testutil.Equal(t, r.URL.Path, "/rest/api/3/issue/PROJ-123/remotelink") | ||
| testutil.Equal(t, r.Method, http.MethodPost) | ||
| capturedBody, _ = io.ReadAll(r.Body) | ||
| w.WriteHeader(http.StatusCreated) | ||
| _ = json.NewEncoder(w).Encode(map[string]any{ | ||
| "id": 10010, | ||
| "self": "https://acme.atlassian.net/rest/api/3/issue/PROJ-123/remotelink/10010", | ||
| }) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) | ||
| testutil.RequireNoError(t, err) | ||
|
|
||
| req := CreateRemoteLinkRequest{ | ||
| Object: RemoteLinkObject{ | ||
| URL: "https://example.com/page", | ||
| Title: "Example", | ||
| }, | ||
| } | ||
| link, err := client.AddRemoteLink(context.Background(), "PROJ-123", req) | ||
| testutil.RequireNoError(t, err) | ||
| // The create response is slim; the returned link echoes the request object. | ||
| testutil.Equal(t, link.ID, 10010) | ||
| testutil.Equal(t, link.Object.URL, "https://example.com/page") | ||
| testutil.Equal(t, link.Object.Title, "Example") | ||
|
|
||
| var sent CreateRemoteLinkRequest | ||
| err = json.Unmarshal(capturedBody, &sent) | ||
| testutil.RequireNoError(t, err) | ||
| testutil.Equal(t, sent.Object.URL, "https://example.com/page") | ||
| testutil.Equal(t, sent.Object.Title, "Example") | ||
| } | ||
|
|
||
| func TestAddRemoteLink_EmptyKey(t *testing.T) { | ||
| _, err := (&Client{}).AddRemoteLink(context.Background(), "", CreateRemoteLinkRequest{ | ||
| Object: RemoteLinkObject{URL: "https://example.com"}, | ||
| }) | ||
| testutil.Equal(t, err, ErrIssueKeyRequired) | ||
| } | ||
|
|
||
| func TestAddRemoteLink_EmptyURL(t *testing.T) { | ||
| _, err := (&Client{}).AddRemoteLink(context.Background(), "PROJ-123", CreateRemoteLinkRequest{}) | ||
| testutil.Equal(t, err, ErrRemoteLinkURLRequired) | ||
| } | ||
|
|
||
| func TestAddRemoteLink_EmptyTitle(t *testing.T) { | ||
| _, err := (&Client{}).AddRemoteLink(context.Background(), "PROJ-123", CreateRemoteLinkRequest{ | ||
| Object: RemoteLinkObject{URL: "https://example.com"}, | ||
| }) | ||
| testutil.Equal(t, err, ErrRemoteLinkTitleRequired) | ||
| } | ||
|
|
||
| func TestDeleteRemoteLink(t *testing.T) { | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| testutil.Equal(t, r.URL.Path, "/rest/api/3/issue/PROJ-123/remotelink/10001") | ||
| testutil.Equal(t, r.Method, http.MethodDelete) | ||
| w.WriteHeader(http.StatusNoContent) | ||
| })) | ||
| defer server.Close() | ||
|
|
||
| client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) | ||
| testutil.RequireNoError(t, err) | ||
|
|
||
| err = client.DeleteRemoteLink(context.Background(), "PROJ-123", 10001) | ||
| testutil.RequireNoError(t, err) | ||
| } | ||
|
|
||
| func TestDeleteRemoteLink_EmptyArgs(t *testing.T) { | ||
| testutil.Equal(t, (&Client{}).DeleteRemoteLink(context.Background(), "", 10001), ErrIssueKeyRequired) | ||
| testutil.Equal(t, (&Client{}).DeleteRemoteLink(context.Background(), "PROJ-123", 0), ErrRemoteLinkIDRequired) | ||
| testutil.Equal(t, (&Client{}).DeleteRemoteLink(context.Background(), "PROJ-123", -1), ErrRemoteLinkIDRequired) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.