From 08469077d9003697b0f7b8b02838b10c052e1294 Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 17 Jun 2026 08:58:21 -0400 Subject: [PATCH 1/5] feat(jtk): add remotelinks subcommand for issue web links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a top-level `jtk remotelinks` command (aliases: `remotelink`, `rl`) to manage an issue's Jira Remote Links / Web Links — external URLs shown in the issue's links sidebar — via the Jira REST API (GET/POST/DELETE /rest/api/3/issue/{key}/remotelink). Subcommands follow the existing issue-scoped CRUD conventions (comments/links/attachments) and the jtk command-surface guardrails: - `remotelinks list ` — list remote links (supports --id / --extended / --fields projection) - `remotelinks add --url --title [--summary --relationship]` — attach a web link (`add` = attach child to parent per the verb table); --title defaults to the URL; --id emits the new link ID - `remotelinks remove ` — detach a web link Layered per the rendering architecture: api.Client methods + DTOs in api/remotelinks.go, a pure RemoteLinkPresenter in internal/present/remotelink.go, and command orchestration in internal/cmd/remotelinks. Adds API, presenter, and command tests, plus OUTPUT_SPEC and README documentation. The repo exposes resource commands at the top level (e.g. `jtk links`, `jtk comments`) rather than under a `jtk issue` parent, so the issue's requested `jtk issue remotelink` surface is delivered as `jtk remotelinks`, preserving the requested add/list/remove verbs. Closes #413 --- README.md | 5 + tools/jtk/api/errors.go | 2 + tools/jtk/api/remotelinks.go | 114 ++++++++ tools/jtk/api/remotelinks_test.go | 118 +++++++++ tools/jtk/cmd/jtk/main.go | 2 + tools/jtk/internal/cmd/OUTPUT_SPEC.md | 36 +++ .../internal/cmd/remotelinks/remotelinks.go | 194 ++++++++++++++ .../cmd/remotelinks/remotelinks_test.go | 250 ++++++++++++++++++ tools/jtk/internal/present/remotelink.go | 110 ++++++++ tools/jtk/internal/present/remotelink_test.go | 201 ++++++++++++++ 10 files changed, 1032 insertions(+) create mode 100644 tools/jtk/api/remotelinks.go create mode 100644 tools/jtk/api/remotelinks_test.go create mode 100644 tools/jtk/internal/cmd/remotelinks/remotelinks.go create mode 100644 tools/jtk/internal/cmd/remotelinks/remotelinks_test.go create mode 100644 tools/jtk/internal/present/remotelink.go create mode 100644 tools/jtk/internal/present/remotelink_test.go diff --git a/README.md b/README.md index dc08bdcd..22f6d605 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,11 @@ jtk links list PROJ-123 jtk links create PROJ-123 PROJ-456 --type Blocks jtk links types +# Remote (web) links — external URLs in the issue sidebar +jtk remotelinks list PROJ-123 +jtk remotelinks add PROJ-123 --url "https://github.com/owner/repo/issues/456" --title "GitHub #456" +jtk remotelinks remove PROJ-123 12345 + # Dashboards jtk dashboards list jtk dashboards create --name "Sprint Board" diff --git a/tools/jtk/api/errors.go b/tools/jtk/api/errors.go index 618ffa30..6f114072 100644 --- a/tools/jtk/api/errors.go +++ b/tools/jtk/api/errors.go @@ -17,6 +17,8 @@ var ( ErrAttachmentContentMissing = errors.New("attachment has no content URL") ErrCommentIDRequired = errors.New("comment ID is required") ErrTaskIDRequired = errors.New("task ID is required") + ErrRemoteLinkIDRequired = errors.New("remote link ID is required") + ErrRemoteLinkURLRequired = errors.New("remote link URL is required") ) // APIError is an alias for the shared APIError type diff --git a/tools/jtk/api/remotelinks.go b/tools/jtk/api/remotelinks.go new file mode 100644 index 00000000..06adbe69 --- /dev/null +++ b/tools/jtk/api/remotelinks.go @@ -0,0 +1,114 @@ +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 +// 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 + } + + 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. +func (c *Client) DeleteRemoteLink(ctx context.Context, issueKey, linkID string) error { + if issueKey == "" { + return ErrIssueKeyRequired + } + if linkID == "" { + return ErrRemoteLinkIDRequired + } + + urlStr := fmt.Sprintf("%s/issue/%s/remotelink/%s", c.BaseURL, url.PathEscape(issueKey), url.PathEscape(linkID)) + if _, err := c.Delete(ctx, urlStr); err != nil { + return fmt.Errorf("deleting remote link %s: %w", linkID, err) + } + return nil +} diff --git a/tools/jtk/api/remotelinks_test.go b/tools/jtk/api/remotelinks_test.go new file mode 100644 index 00000000..a779a5ad --- /dev/null +++ b/tools/jtk/api/remotelinks_test.go @@ -0,0 +1,118 @@ +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 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", ""), ErrRemoteLinkIDRequired) +} diff --git a/tools/jtk/cmd/jtk/main.go b/tools/jtk/cmd/jtk/main.go index 50f2b3d5..b9cb8ce7 100644 --- a/tools/jtk/cmd/jtk/main.go +++ b/tools/jtk/cmd/jtk/main.go @@ -30,6 +30,7 @@ import ( "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/me" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/projects" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/refresh" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/remotelinks" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/setcredential" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/sprints" @@ -67,6 +68,7 @@ func run(ctx context.Context) error { transitions.Register(rootCmd, opts) comments.Register(rootCmd, opts) links.Register(rootCmd, opts) + remotelinks.Register(rootCmd, opts) attachments.Register(rootCmd, opts) automation.Register(rootCmd, opts) boards.Register(rootCmd, opts) diff --git a/tools/jtk/internal/cmd/OUTPUT_SPEC.md b/tools/jtk/internal/cmd/OUTPUT_SPEC.md index 1fb20651..1f1d0ef9 100644 --- a/tools/jtk/internal/cmd/OUTPUT_SPEC.md +++ b/tools/jtk/internal/cmd/OUTPUT_SPEC.md @@ -460,6 +460,42 @@ ID | NAME | INWARD | OUTWARD Cached during init/refresh. `links create` accepts the type name ("Blocker"), the outward verb ("blocks"), or the inward verb ("is blocked by"). +### `remotelinks` + +Remote (web) links are external URLs attached to an issue and shown in the Jira links sidebar — distinct from `links`, which connect two Jira issues. + +**`remotelinks list MON-4818`** — default: +``` +ID | TITLE | URL +10001 | GitHub #456: Some issue | https://github.com/owner/repo/issues/456 +10002 | Design doc | https://example.com/design +``` + +**`remotelinks list MON-4818 --extended`:** +``` +ID | RELATIONSHIP | TITLE | URL | SUMMARY +10001 | mentioned in | GitHub #456: Some issue | https://github.com/owner/repo/issues/456 | Tracks the upstream fix +10002 | - | Design doc | https://example.com/design | - +``` + +Extended adds the relationship label and the link summary. + +**`remotelinks add MON-4818 --url ... --title ...`** — post-state detail: +``` +Added remote link 10001 to MON-4818 +ID: 10001 +Issue: MON-4818 +Title: GitHub #456: Some issue +URL: https://github.com/owner/repo/issues/456 +``` + +`--title` defaults to the URL when omitted. `--id` emits only the new link ID. + +**`remotelinks remove MON-4818 10001`** — confirmation line only: +``` +Removed remote link 10001 from MON-4818 +``` + ### `transitions` **`transitions list MON-4810`** — default: diff --git a/tools/jtk/internal/cmd/remotelinks/remotelinks.go b/tools/jtk/internal/cmd/remotelinks/remotelinks.go new file mode 100644 index 00000000..cae73a2d --- /dev/null +++ b/tools/jtk/internal/cmd/remotelinks/remotelinks.go @@ -0,0 +1,194 @@ +// Package remotelinks provides CLI commands for managing Jira issue remote +// (web) links — external URLs shown in an issue's links sidebar. +package remotelinks + +import ( + "context" + "strconv" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" + jtkpresent "github.com/open-cli-collective/jira-ticket-cli/internal/present" + "github.com/open-cli-collective/jira-ticket-cli/internal/present/projection" + "github.com/open-cli-collective/jira-ticket-cli/internal/text" +) + +// noFieldFetch is the projection.Resolve fetcher for remote links. Remote +// link fields are not Jira issue fields, so there is no metadata to fetch; +// returning nil routes deferred tokens cleanly to UnknownFieldError rather +// than a real network call against /rest/api/3/field. +func noFieldFetch(_ context.Context) ([]api.Field, error) { return nil, nil } + +// Register registers the remotelinks commands. +func Register(parent *cobra.Command, opts *root.Options) { + cmd := &cobra.Command{ + Use: "remotelinks", + Aliases: []string{"remotelink", "rl"}, + Short: "Manage issue remote (web) links", + Long: "Commands for listing, adding, and removing an issue's remote (web) links — external URLs shown in the Jira issue links sidebar.", + } + + cmd.AddCommand(newListCmd(opts)) + cmd.AddCommand(newAddCmd(opts)) + cmd.AddCommand(newRemoveCmd(opts)) + + parent.AddCommand(cmd) +} + +func newListCmd(opts *root.Options) *cobra.Command { + var fieldsFlag string + + cmd := &cobra.Command{ + Use: "list ", + Short: "List remote links on an issue", + Long: "List all remote (web) links on a specific issue.", + Example: ` jtk remotelinks list PROJ-123 + jtk remotelinks list PROJ-123 --extended + jtk remotelinks list PROJ-123 --id + jtk remotelinks list PROJ-123 --fields TITLE,URL`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runList(cmd.Context(), opts, args[0], fieldsFlag) + }, + } + + cmd.Flags().StringVar(&fieldsFlag, "fields", "", "Comma-separated display columns") + + return cmd +} + +func runList(ctx context.Context, opts *root.Options, issueKey, fieldsFlag string) error { + idOnly := opts.EmitIDOnly() + + var selected []projection.ColumnSpec + var projected bool + if !idOnly { + var err error + selected, projected, err = projection.Resolve( + ctx, + jtkpresent.RemoteLinkListSpec, + opts.IsExtended(), + fieldsFlag, + noFieldFetch, + "remotelinks list", + ) + if err != nil { + return err + } + } + + client, err := opts.APIClient() + if err != nil { + return err + } + + links, err := client.GetRemoteLinks(ctx, issueKey) + if err != nil { + return err + } + + if idOnly { + ids := make([]string, len(links)) + for i, l := range links { + ids[i] = strconv.Itoa(l.ID) + } + return jtkpresent.EmitIDs(opts, ids) + } + + if len(links) == 0 { + return jtkpresent.Emit(opts, jtkpresent.RemoteLinkPresenter{}.PresentEmpty(issueKey)) + } + + model := jtkpresent.RemoteLinkPresenter{}.PresentList(links, opts.IsExtended()) + if projected { + projection.ApplyToTableInModel(model, selected) + } + return jtkpresent.Emit(opts, model) +} + +func newAddCmd(opts *root.Options) *cobra.Command { + var url, title, summary, relationship string + + cmd := &cobra.Command{ + Use: "add ", + Short: "Add a remote (web) link to an issue", + Long: "Add a remote (web) link to an issue, pointing at an external URL such as a GitHub issue or a documentation page.", + Example: ` jtk remotelinks add PROJ-123 --url "https://github.com/owner/repo/issues/456" --title "GitHub #456: Some issue" + jtk remotelinks add PROJ-123 --url "https://example.com" --title "Docs" --summary "Reference docs"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAdd(cmd.Context(), opts, args[0], url, title, summary, relationship) + }, + } + + cmd.Flags().StringVar(&url, "url", "", "External URL the link points at (required)") + cmd.Flags().StringVar(&title, "title", "", "Display title for the link (defaults to the URL)") + cmd.Flags().StringVar(&summary, "summary", "", "Optional one-line summary shown under the title") + cmd.Flags().StringVar(&relationship, "relationship", "", "Optional relationship label (e.g. \"mentioned in\")") + _ = cmd.MarkFlagRequired("url") + + return cmd +} + +func runAdd(ctx context.Context, opts *root.Options, issueKey, url, title, summary, relationship string) error { + client, err := opts.APIClient() + if err != nil { + return err + } + + // Title defaults to the URL so the link is never anonymous in the sidebar. + if title == "" { + title = url + } + + req := api.CreateRemoteLinkRequest{ + Relationship: text.InterpretEscapes(relationship), + Object: api.RemoteLinkObject{ + URL: url, + Title: text.InterpretEscapes(title), + Summary: text.InterpretEscapes(summary), + }, + } + + link, err := client.AddRemoteLink(ctx, issueKey, req) + if err != nil { + return err + } + + if opts.EmitIDOnly() { + return jtkpresent.EmitIDs(opts, []string{strconv.Itoa(link.ID)}) + } + + return jtkpresent.Emit(opts, jtkpresent.RemoteLinkPresenter{}.PresentAddedDetail(issueKey, link)) +} + +func newRemoveCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove a remote (web) link from an issue", + Long: "Remove a remote (web) link from an issue by its ID. Use 'jtk remotelinks list' to find link IDs.", + Example: ` jtk remotelinks remove PROJ-123 12345 + jtk remotelinks list PROJ-123 # find link IDs first`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runRemove(cmd.Context(), opts, args[0], args[1]) + }, + } + + return cmd +} + +func runRemove(ctx context.Context, opts *root.Options, issueKey, linkID string) error { + client, err := opts.APIClient() + if err != nil { + return err + } + + if err := client.DeleteRemoteLink(ctx, issueKey, linkID); err != nil { + return err + } + + return jtkpresent.Emit(opts, jtkpresent.RemoteLinkPresenter{}.PresentRemoved(linkID, issueKey)) +} diff --git a/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go b/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go new file mode 100644 index 00000000..a56f705a --- /dev/null +++ b/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go @@ -0,0 +1,250 @@ +package remotelinks + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/open-cli-collective/atlassian-go/testutil" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +func TestNewListCmd(t *testing.T) { + t.Parallel() + cmd := newListCmd(&root.Options{}) + testutil.Equal(t, cmd.Use, "list ") + testutil.Equal(t, cmd.Short, "List remote links on an issue") +} + +func TestNewAddCmd_RequiresURL(t *testing.T) { + t.Parallel() + cmd := newAddCmd(&root.Options{}) + testutil.Equal(t, cmd.Use, "add ") + // --url is marked required. + flag := cmd.Flags().Lookup("url") + testutil.NotNil(t, flag) +} + +func remoteLinkListServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "id": 10001, + "relationship": "mentioned in", + "object": map[string]any{ + "url": "https://github.com/owner/repo/issues/456", + "title": "GitHub #456", + "summary": "Some issue", + }, + }, + }) + })) +} + +func TestRunList(t *testing.T) { + t.Parallel() + server := remoteLinkListServer(t) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runList(context.Background(), opts, "PROJ-123", "") + testutil.RequireNoError(t, err) + out := stdout.String() + testutil.Contains(t, out, "10001") + testutil.Contains(t, out, "GitHub #456") + testutil.Contains(t, out, "https://github.com/owner/repo/issues/456") +} + +func TestRunList_Extended(t *testing.T) { + t.Parallel() + server := remoteLinkListServer(t) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}, Extended: true} + opts.SetAPIClient(client) + + err = runList(context.Background(), opts, "PROJ-123", "") + testutil.RequireNoError(t, err) + out := stdout.String() + testutil.Contains(t, out, "RELATIONSHIP") + testutil.Contains(t, out, "SUMMARY") + testutil.Contains(t, out, "mentioned in") +} + +func TestRunList_IDOnly(t *testing.T) { + t.Parallel() + server := remoteLinkListServer(t) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}, IDOnly: true} + opts.SetAPIClient(client) + + err = runList(context.Background(), opts, "PROJ-123", "") + testutil.RequireNoError(t, err) + testutil.Equal(t, stdout.String(), "10001\n") +} + +func TestRunList_FieldsProjection(t *testing.T) { + t.Parallel() + server := remoteLinkListServer(t) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runList(context.Background(), opts, "PROJ-123", "TITLE") + testutil.RequireNoError(t, err) + out := stdout.String() + // ID is always present (Identity pin) even though not in --fields. + testutil.Contains(t, out, "ID") + testutil.Contains(t, out, "TITLE") + testutil.NotContains(t, out, "URL") +} + +func TestRunList_NoLinks(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode([]any{}) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runList(context.Background(), opts, "PROJ-123", "") + testutil.RequireNoError(t, err) + testutil.Contains(t, stdout.String(), "No remote links on PROJ-123") +} + +func TestRunAdd(t *testing.T) { + t.Parallel() + 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}) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runAdd(context.Background(), opts, "PROJ-123", "https://example.com", "Example", "", "") + testutil.RequireNoError(t, err) + out := stdout.String() + testutil.Contains(t, out, "Added remote link 10010 to PROJ-123") + testutil.Contains(t, out, "https://example.com") + + var sent api.CreateRemoteLinkRequest + err = json.Unmarshal(capturedBody, &sent) + testutil.RequireNoError(t, err) + testutil.Equal(t, sent.Object.URL, "https://example.com") + testutil.Equal(t, sent.Object.Title, "Example") +} + +func TestRunAdd_TitleDefaultsToURL(t *testing.T) { + t.Parallel() + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"id": 10011}) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + opts := &root.Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runAdd(context.Background(), opts, "PROJ-123", "https://example.com", "", "", "") + testutil.RequireNoError(t, err) + + var sent api.CreateRemoteLinkRequest + err = json.Unmarshal(capturedBody, &sent) + testutil.RequireNoError(t, err) + testutil.Equal(t, sent.Object.Title, "https://example.com") +} + +func TestRunAdd_IDOnly(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"id": 10012}) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &bytes.Buffer{}, IDOnly: true} + opts.SetAPIClient(client) + + err = runAdd(context.Background(), opts, "PROJ-123", "https://example.com", "Example", "", "") + testutil.RequireNoError(t, err) + testutil.Equal(t, stdout.String(), "10012\n") +} + +func TestRunRemove(t *testing.T) { + t.Parallel() + 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 := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + var stdout, stderr bytes.Buffer + opts := &root.Options{Stdout: &stdout, Stderr: &stderr} + opts.SetAPIClient(client) + + err = runRemove(context.Background(), opts, "PROJ-123", "10001") + testutil.RequireNoError(t, err) + testutil.Equal(t, stdout.String(), "Removed remote link 10001 from PROJ-123\n") + testutil.Equal(t, stderr.String(), "") +} diff --git a/tools/jtk/internal/present/remotelink.go b/tools/jtk/internal/present/remotelink.go new file mode 100644 index 00000000..fefe930d --- /dev/null +++ b/tools/jtk/internal/present/remotelink.go @@ -0,0 +1,110 @@ +// Package present provides presenters that map domain types to presentation models. +package present + +import ( + "fmt" + "strconv" + + "github.com/open-cli-collective/atlassian-go/present" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/present/projection" +) + +// RemoteLinkPresenter creates presentation models for issue remote (web) links. +type RemoteLinkPresenter struct{} + +// RemoteLinkListSpec declares the columns emitted by PresentList. Default: +// ID|TITLE|URL. Extended: ID|RELATIONSHIP|TITLE|URL|SUMMARY. None of these +// map to Jira issue fields, so unknown --fields tokens correctly resolve to +// UnknownFieldError rather than a real /rest/api/3/field lookup. +var RemoteLinkListSpec = projection.Registry{ + {Header: "ID", Identity: true}, + {Header: "RELATIONSHIP", Extended: true}, + {Header: "TITLE"}, + {Header: "URL"}, + {Header: "SUMMARY", Extended: true}, +} + +// PresentList creates a table presentation of remote links. Extended adds the +// RELATIONSHIP and SUMMARY columns. +func (RemoteLinkPresenter) PresentList(links []api.RemoteLink, extended bool) *present.OutputModel { + var headers []string + if extended { + headers = []string{"ID", "RELATIONSHIP", "TITLE", "URL", "SUMMARY"} + } else { + headers = []string{"ID", "TITLE", "URL"} + } + + rows := make([]present.Row, len(links)) + for i, l := range links { + id := strconv.Itoa(l.ID) + if extended { + rows[i] = present.Row{ + Cells: []string{id, OrDash(l.Relationship), OrDash(l.Object.Title), l.Object.URL, OrDash(l.Object.Summary)}, + } + } else { + rows[i] = present.Row{ + Cells: []string{id, OrDash(l.Object.Title), l.Object.URL}, + } + } + } + return &present.OutputModel{ + Sections: []present.Section{ + &present.TableSection{Headers: headers, Rows: rows}, + }, + } +} + +// PresentAddedDetail creates a post-state detail block for a newly added +// remote link, mirroring the `get`-style shape used by other mutations. +func (RemoteLinkPresenter) PresentAddedDetail(issueKey string, l *api.RemoteLink) *present.OutputModel { + fields := []present.Field{ + {Label: "ID", Value: strconv.Itoa(l.ID)}, + {Label: "Issue", Value: issueKey}, + {Label: "Title", Value: OrDash(l.Object.Title)}, + {Label: "URL", Value: l.Object.URL}, + } + if l.Relationship != "" { + fields = append(fields, present.Field{Label: "Relationship", Value: l.Relationship}) + } + if l.Object.Summary != "" { + fields = append(fields, present.Field{Label: "Summary", Value: l.Object.Summary}) + } + return &present.OutputModel{ + Sections: []present.Section{ + &present.MessageSection{ + Kind: present.MessageSuccess, + Message: fmt.Sprintf("Added remote link %d to %s", l.ID, issueKey), + Stream: present.StreamStdout, + }, + &present.DetailSection{Fields: fields}, + }, + } +} + +// PresentRemoved creates a success message for remote link removal. +func (RemoteLinkPresenter) PresentRemoved(linkID, issueKey string) *present.OutputModel { + return &present.OutputModel{ + Sections: []present.Section{ + &present.MessageSection{ + Kind: present.MessageSuccess, + Message: fmt.Sprintf("Removed remote link %s from %s", linkID, issueKey), + Stream: present.StreamStdout, + }, + }, + } +} + +// PresentEmpty creates an info message when no remote links are found. +func (RemoteLinkPresenter) PresentEmpty(issueKey string) *present.OutputModel { + return &present.OutputModel{ + Sections: []present.Section{ + &present.MessageSection{ + Kind: present.MessageInfo, + Message: fmt.Sprintf("No remote links on %s", issueKey), + Stream: present.StreamStdout, + }, + }, + } +} diff --git a/tools/jtk/internal/present/remotelink_test.go b/tools/jtk/internal/present/remotelink_test.go new file mode 100644 index 00000000..36751519 --- /dev/null +++ b/tools/jtk/internal/present/remotelink_test.go @@ -0,0 +1,201 @@ +package present + +import ( + "testing" + + "github.com/open-cli-collective/atlassian-go/present" + + "github.com/open-cli-collective/jira-ticket-cli/api" +) + +func TestRemoteLinkListSpec_MatchesPresentListHeaders(t *testing.T) { + t.Parallel() + links := []api.RemoteLink{{ + ID: 10001, + Relationship: "mentioned in", + Object: api.RemoteLinkObject{ + URL: "https://example.com", + Title: "Example", + Summary: "A summary", + }, + }} + + for _, extended := range []bool{false, true} { + name := "default" + if extended { + name = "extended" + } + t.Run(name, func(t *testing.T) { + specs := RemoteLinkListSpec.ForMode(extended) + model := RemoteLinkPresenter{}.PresentList(links, extended) + table := model.Sections[0].(*present.TableSection) + + if len(table.Headers) != len(specs) { + t.Fatalf("header count mismatch: spec has %d, table has %d", len(specs), len(table.Headers)) + } + for i, spec := range specs { + if table.Headers[i] != spec.Header { + t.Errorf("index %d: spec=%q, table=%q", i, spec.Header, table.Headers[i]) + } + } + }) + } +} + +func TestRemoteLinkPresenter_PresentList_Default_CellOrder(t *testing.T) { + t.Parallel() + links := []api.RemoteLink{{ + ID: 10001, + Object: api.RemoteLinkObject{ + URL: "https://github.com/owner/repo/issues/456", + Title: "GitHub #456", + }, + }} + + model := RemoteLinkPresenter{}.PresentList(links, false) + table := model.Sections[0].(*present.TableSection) + + want := []string{"10001", "GitHub #456", "https://github.com/owner/repo/issues/456"} + row := table.Rows[0].Cells + if len(row) != len(want) { + t.Fatalf("expected %d cells, got %d", len(want), len(row)) + } + for i, w := range want { + if row[i] != w { + t.Errorf("cell[%d]: expected %q, got %q", i, w, row[i]) + } + } +} + +func TestRemoteLinkPresenter_PresentList_Extended(t *testing.T) { + t.Parallel() + links := []api.RemoteLink{ + { + ID: 10001, + Relationship: "mentioned in", + Object: api.RemoteLinkObject{ + URL: "https://example.com", + Title: "Example", + Summary: "Summary text", + }, + }, + { + // No title, relationship, or summary → dash placeholders. + ID: 10002, + Object: api.RemoteLinkObject{ + URL: "https://other.example", + }, + }, + } + + model := RemoteLinkPresenter{}.PresentList(links, true) + table := model.Sections[0].(*present.TableSection) + + expectedHeaders := []string{"ID", "RELATIONSHIP", "TITLE", "URL", "SUMMARY"} + if len(table.Headers) != len(expectedHeaders) { + t.Fatalf("expected %d headers, got %d", len(expectedHeaders), len(table.Headers)) + } + for i, h := range expectedHeaders { + if table.Headers[i] != h { + t.Errorf("header[%d]: expected %q, got %q", i, h, table.Headers[i]) + } + } + + wantR0 := []string{"10001", "mentioned in", "Example", "https://example.com", "Summary text"} + for i, w := range wantR0 { + if table.Rows[0].Cells[i] != w { + t.Errorf("row0[%d] (%s): expected %q, got %q", i, expectedHeaders[i], w, table.Rows[0].Cells[i]) + } + } + + wantR1 := []string{"10002", "-", "-", "https://other.example", "-"} + for i, w := range wantR1 { + if table.Rows[1].Cells[i] != w { + t.Errorf("row1[%d] (%s): expected %q, got %q", i, expectedHeaders[i], w, table.Rows[1].Cells[i]) + } + } +} + +func TestRemoteLinkPresenter_PresentAddedDetail(t *testing.T) { + t.Parallel() + link := &api.RemoteLink{ + ID: 10010, + Relationship: "mentioned in", + Object: api.RemoteLinkObject{ + URL: "https://example.com", + Title: "Example", + Summary: "Summary", + }, + } + + model := RemoteLinkPresenter{}.PresentAddedDetail("PROJ-123", link) + + msg := model.Sections[0].(*present.MessageSection) + if msg.Kind != present.MessageSuccess { + t.Errorf("want MessageSuccess, got %v", msg.Kind) + } + if msg.Stream != present.StreamStdout { + t.Errorf("want StreamStdout, got %v", msg.Stream) + } + if msg.Message != "Added remote link 10010 to PROJ-123" { + t.Errorf("unexpected message: %q", msg.Message) + } + + detail := model.Sections[1].(*present.DetailSection) + got := map[string]string{} + for _, f := range detail.Fields { + got[f.Label] = f.Value + } + for label, want := range map[string]string{ + "ID": "10010", + "Issue": "PROJ-123", + "Title": "Example", + "URL": "https://example.com", + "Relationship": "mentioned in", + "Summary": "Summary", + } { + if got[label] != want { + t.Errorf("field %q: expected %q, got %q", label, want, got[label]) + } + } +} + +func TestRemoteLinkPresenter_PresentAddedDetail_OmitsEmptyOptional(t *testing.T) { + t.Parallel() + link := &api.RemoteLink{ + ID: 10011, + Object: api.RemoteLinkObject{URL: "https://example.com", Title: "Example"}, + } + + model := RemoteLinkPresenter{}.PresentAddedDetail("PROJ-123", link) + detail := model.Sections[1].(*present.DetailSection) + for _, f := range detail.Fields { + if f.Label == "Relationship" || f.Label == "Summary" { + t.Errorf("optional field %q should be omitted when empty", f.Label) + } + } +} + +func TestRemoteLinkPresenter_PresentRemoved(t *testing.T) { + t.Parallel() + model := RemoteLinkPresenter{}.PresentRemoved("10001", "PROJ-123") + msg := model.Sections[0].(*present.MessageSection) + if msg.Kind != present.MessageSuccess { + t.Errorf("want MessageSuccess, got %v", msg.Kind) + } + if msg.Message != "Removed remote link 10001 from PROJ-123" { + t.Errorf("unexpected message: %q", msg.Message) + } +} + +func TestRemoteLinkPresenter_PresentEmpty(t *testing.T) { + t.Parallel() + model := RemoteLinkPresenter{}.PresentEmpty("PROJ-123") + msg := model.Sections[0].(*present.MessageSection) + if msg.Kind != present.MessageInfo { + t.Errorf("want MessageInfo, got %v", msg.Kind) + } + if msg.Message != "No remote links on PROJ-123" { + t.Errorf("unexpected message: %q", msg.Message) + } +} From a8f7661484aa3169614043a90d63b9709159a0db Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 17 Jun 2026 09:43:04 -0400 Subject: [PATCH 2/5] refactor(jtk): address review feedback on remotelinks - present: drive PresentList headers and column selection from RemoteLinkListSpec via projection.ProjectTable + ForMode instead of hard-coding two parallel extended/default header+row paths, so the registry stays the single source of truth for columns. - api: DeleteRemoteLink now takes linkID int (matching RemoteLink.ID), guards linkID <= 0 with ErrRemoteLinkIDRequired, and formats the URL with %d; runRemove parses args[1] with strconv.Atoi and returns a user-facing error for non-numeric input before the API call. - api: AddRemoteLink validates req.Object.Title with a new ErrRemoteLinkTitleRequired sentinel (Jira requires object.title). - tests: update TestDeleteRemoteLink to int IDs (+ linkID <= 0 cases), add TestAddRemoteLink_EmptyTitle and TestRunRemove_NonNumericID. --- tools/jtk/api/errors.go | 1 + tools/jtk/api/remotelinks.go | 15 ++++-- tools/jtk/api/remotelinks_test.go | 14 ++++-- .../internal/cmd/remotelinks/remotelinks.go | 12 ++++- .../cmd/remotelinks/remotelinks_test.go | 11 +++++ tools/jtk/internal/present/remotelink.go | 49 +++++++++++-------- 6 files changed, 71 insertions(+), 31 deletions(-) diff --git a/tools/jtk/api/errors.go b/tools/jtk/api/errors.go index 6f114072..00a1fb49 100644 --- a/tools/jtk/api/errors.go +++ b/tools/jtk/api/errors.go @@ -19,6 +19,7 @@ var ( ErrTaskIDRequired = errors.New("task ID is required") ErrRemoteLinkIDRequired = errors.New("remote link ID is required") ErrRemoteLinkURLRequired = errors.New("remote link URL is required") + ErrRemoteLinkTitleRequired = errors.New("remote link title is required") ) // APIError is an alias for the shared APIError type diff --git a/tools/jtk/api/remotelinks.go b/tools/jtk/api/remotelinks.go index 06adbe69..c96cc9ae 100644 --- a/tools/jtk/api/remotelinks.go +++ b/tools/jtk/api/remotelinks.go @@ -76,6 +76,9 @@ func (c *Client) AddRemoteLink(ctx context.Context, issueKey string, req CreateR if req.Object.URL == "" { return nil, ErrRemoteLinkURLRequired } + 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) @@ -97,18 +100,20 @@ func (c *Client) AddRemoteLink(ctx context.Context, issueKey string, req CreateR }, nil } -// DeleteRemoteLink deletes a remote link from an issue by its link ID. -func (c *Client) DeleteRemoteLink(ctx context.Context, issueKey, linkID string) error { +// 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 == "" { + if linkID <= 0 { return ErrRemoteLinkIDRequired } - urlStr := fmt.Sprintf("%s/issue/%s/remotelink/%s", c.BaseURL, url.PathEscape(issueKey), url.PathEscape(linkID)) + 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 %s: %w", linkID, err) + return fmt.Errorf("deleting remote link %d: %w", linkID, err) } return nil } diff --git a/tools/jtk/api/remotelinks_test.go b/tools/jtk/api/remotelinks_test.go index a779a5ad..d782a248 100644 --- a/tools/jtk/api/remotelinks_test.go +++ b/tools/jtk/api/remotelinks_test.go @@ -97,6 +97,13 @@ func TestAddRemoteLink_EmptyURL(t *testing.T) { 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") @@ -108,11 +115,12 @@ func TestDeleteRemoteLink(t *testing.T) { 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") + 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", ""), ErrRemoteLinkIDRequired) + 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) } diff --git a/tools/jtk/internal/cmd/remotelinks/remotelinks.go b/tools/jtk/internal/cmd/remotelinks/remotelinks.go index cae73a2d..f128f5f2 100644 --- a/tools/jtk/internal/cmd/remotelinks/remotelinks.go +++ b/tools/jtk/internal/cmd/remotelinks/remotelinks.go @@ -4,6 +4,7 @@ package remotelinks import ( "context" + "fmt" "strconv" "github.com/spf13/cobra" @@ -180,7 +181,14 @@ func newRemoveCmd(opts *root.Options) *cobra.Command { return cmd } -func runRemove(ctx context.Context, opts *root.Options, issueKey, linkID string) error { +func runRemove(ctx context.Context, opts *root.Options, issueKey, linkIDArg string) error { + // Remote link IDs are integers; reject typos before the API call so the + // user gets a clear message instead of a server-side 404. + linkID, err := strconv.Atoi(linkIDArg) + if err != nil { + return fmt.Errorf("invalid link ID %q: must be a number", linkIDArg) + } + client, err := opts.APIClient() if err != nil { return err @@ -190,5 +198,5 @@ func runRemove(ctx context.Context, opts *root.Options, issueKey, linkID string) return err } - return jtkpresent.Emit(opts, jtkpresent.RemoteLinkPresenter{}.PresentRemoved(linkID, issueKey)) + return jtkpresent.Emit(opts, jtkpresent.RemoteLinkPresenter{}.PresentRemoved(strconv.Itoa(linkID), issueKey)) } diff --git a/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go b/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go index a56f705a..ae18e9c0 100644 --- a/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go +++ b/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go @@ -248,3 +248,14 @@ func TestRunRemove(t *testing.T) { testutil.Equal(t, stdout.String(), "Removed remote link 10001 from PROJ-123\n") testutil.Equal(t, stderr.String(), "") } + +func TestRunRemove_NonNumericID(t *testing.T) { + t.Parallel() + // A non-numeric link ID is rejected before any API call. No client is set + // on opts, so reaching the API would panic — proving validation comes first. + opts := &root.Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + + err := runRemove(context.Background(), opts, "PROJ-123", "not-a-number") + testutil.RequireError(t, err) + testutil.Contains(t, err.Error(), "invalid link ID") +} diff --git a/tools/jtk/internal/present/remotelink.go b/tools/jtk/internal/present/remotelink.go index fefe930d..d8bff4af 100644 --- a/tools/jtk/internal/present/remotelink.go +++ b/tools/jtk/internal/present/remotelink.go @@ -26,33 +26,40 @@ var RemoteLinkListSpec = projection.Registry{ {Header: "SUMMARY", Extended: true}, } -// PresentList creates a table presentation of remote links. Extended adds the -// RELATIONSHIP and SUMMARY columns. +// PresentList creates a table presentation of remote links. Both headers and +// column selection are driven from RemoteLinkListSpec: a single row carrying +// every column is built, then projected down to the active mode's columns via +// the registry's Extended flags. Extended adds the RELATIONSHIP and SUMMARY +// columns. This keeps the presenter from re-enumerating columns that the spec +// already declares. func (RemoteLinkPresenter) PresentList(links []api.RemoteLink, extended bool) *present.OutputModel { - var headers []string - if extended { - headers = []string{"ID", "RELATIONSHIP", "TITLE", "URL", "SUMMARY"} - } else { - headers = []string{"ID", "TITLE", "URL"} - } - rows := make([]present.Row, len(links)) for i, l := range links { - id := strconv.Itoa(l.ID) - if extended { - rows[i] = present.Row{ - Cells: []string{id, OrDash(l.Relationship), OrDash(l.Object.Title), l.Object.URL, OrDash(l.Object.Summary)}, - } - } else { - rows[i] = present.Row{ - Cells: []string{id, OrDash(l.Object.Title), l.Object.URL}, - } + rows[i] = present.Row{ + // Column order MUST match RemoteLinkListSpec. + Cells: []string{ + strconv.Itoa(l.ID), + OrDash(l.Relationship), + OrDash(l.Object.Title), + l.Object.URL, + OrDash(l.Object.Summary), + }, } } + + headers := make([]string, len(RemoteLinkListSpec)) + for i, spec := range RemoteLinkListSpec { + headers[i] = spec.Header + } + + // Strip non-extended columns using the registry's Extended flags, the same + // path commands take via projection.ApplyToTableInModel for --fields. + section := projection.ProjectTable( + &present.TableSection{Headers: headers, Rows: rows}, + RemoteLinkListSpec.ForMode(extended), + ) return &present.OutputModel{ - Sections: []present.Section{ - &present.TableSection{Headers: headers, Rows: rows}, - }, + Sections: []present.Section{section}, } } From 4d7f9919709ddd58b3ac955346450882bffac296 Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 17 Jun 2026 10:32:38 -0400 Subject: [PATCH 3/5] refactor(jtk): pass int linkID to PresentRemoved; note remotelink verbs PresentRemoved now accepts linkID int and formats with %d, dropping the int->string round-trip in runRemove now that DeleteRemoteLink takes int. Add a brief README note that remotelinks uses add/remove to mirror the Jira Remote Links API attach/detach semantics. --- README.md | 4 +++- tools/jtk/internal/cmd/remotelinks/remotelinks.go | 2 +- tools/jtk/internal/present/remotelink.go | 4 ++-- tools/jtk/internal/present/remotelink_test.go | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 22f6d605..2c257b2a 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,9 @@ jtk links list PROJ-123 jtk links create PROJ-123 PROJ-456 --type Blocks jtk links types -# Remote (web) links — external URLs in the issue sidebar +# Remote (web) links — external URLs in the issue sidebar. +# Verbs are add/remove (not create) to mirror the Jira Remote Links API's +# attach/detach semantics. jtk remotelinks list PROJ-123 jtk remotelinks add PROJ-123 --url "https://github.com/owner/repo/issues/456" --title "GitHub #456" jtk remotelinks remove PROJ-123 12345 diff --git a/tools/jtk/internal/cmd/remotelinks/remotelinks.go b/tools/jtk/internal/cmd/remotelinks/remotelinks.go index f128f5f2..87aeba9f 100644 --- a/tools/jtk/internal/cmd/remotelinks/remotelinks.go +++ b/tools/jtk/internal/cmd/remotelinks/remotelinks.go @@ -198,5 +198,5 @@ func runRemove(ctx context.Context, opts *root.Options, issueKey, linkIDArg stri return err } - return jtkpresent.Emit(opts, jtkpresent.RemoteLinkPresenter{}.PresentRemoved(strconv.Itoa(linkID), issueKey)) + return jtkpresent.Emit(opts, jtkpresent.RemoteLinkPresenter{}.PresentRemoved(linkID, issueKey)) } diff --git a/tools/jtk/internal/present/remotelink.go b/tools/jtk/internal/present/remotelink.go index d8bff4af..d932a3f4 100644 --- a/tools/jtk/internal/present/remotelink.go +++ b/tools/jtk/internal/present/remotelink.go @@ -91,12 +91,12 @@ func (RemoteLinkPresenter) PresentAddedDetail(issueKey string, l *api.RemoteLink } // PresentRemoved creates a success message for remote link removal. -func (RemoteLinkPresenter) PresentRemoved(linkID, issueKey string) *present.OutputModel { +func (RemoteLinkPresenter) PresentRemoved(linkID int, issueKey string) *present.OutputModel { return &present.OutputModel{ Sections: []present.Section{ &present.MessageSection{ Kind: present.MessageSuccess, - Message: fmt.Sprintf("Removed remote link %s from %s", linkID, issueKey), + Message: fmt.Sprintf("Removed remote link %d from %s", linkID, issueKey), Stream: present.StreamStdout, }, }, diff --git a/tools/jtk/internal/present/remotelink_test.go b/tools/jtk/internal/present/remotelink_test.go index 36751519..3c4d6619 100644 --- a/tools/jtk/internal/present/remotelink_test.go +++ b/tools/jtk/internal/present/remotelink_test.go @@ -178,7 +178,7 @@ func TestRemoteLinkPresenter_PresentAddedDetail_OmitsEmptyOptional(t *testing.T) func TestRemoteLinkPresenter_PresentRemoved(t *testing.T) { t.Parallel() - model := RemoteLinkPresenter{}.PresentRemoved("10001", "PROJ-123") + model := RemoteLinkPresenter{}.PresentRemoved(10001, "PROJ-123") msg := model.Sections[0].(*present.MessageSection) if msg.Kind != present.MessageSuccess { t.Errorf("want MessageSuccess, got %v", msg.Kind) From 5023be483db321acb901d341dc263000e1541cbd Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Wed, 24 Jun 2026 19:39:43 -0400 Subject: [PATCH 4/5] refactor(jtk): use delete for remotelinks --- README.md | 6 +++--- tools/jtk/internal/cmd/OUTPUT_SPEC.md | 4 ++-- .../internal/cmd/remotelinks/remotelinks.go | 20 +++++++++---------- .../cmd/remotelinks/remotelinks_test.go | 10 +++++----- tools/jtk/internal/present/remotelink.go | 6 +++--- tools/jtk/internal/present/remotelink_test.go | 6 +++--- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 2c257b2a..196179ab 100644 --- a/README.md +++ b/README.md @@ -317,11 +317,11 @@ jtk links create PROJ-123 PROJ-456 --type Blocks jtk links types # Remote (web) links — external URLs in the issue sidebar. -# Verbs are add/remove (not create) to mirror the Jira Remote Links API's -# attach/detach semantics. +# Verbs are add/delete: remote links are attached to an issue, but deleting +# one destroys the remote-link record rather than merely detaching it. jtk remotelinks list PROJ-123 jtk remotelinks add PROJ-123 --url "https://github.com/owner/repo/issues/456" --title "GitHub #456" -jtk remotelinks remove PROJ-123 12345 +jtk remotelinks delete PROJ-123 12345 # Dashboards jtk dashboards list diff --git a/tools/jtk/internal/cmd/OUTPUT_SPEC.md b/tools/jtk/internal/cmd/OUTPUT_SPEC.md index 1f1d0ef9..3678e07b 100644 --- a/tools/jtk/internal/cmd/OUTPUT_SPEC.md +++ b/tools/jtk/internal/cmd/OUTPUT_SPEC.md @@ -491,9 +491,9 @@ URL: https://github.com/owner/repo/issues/456 `--title` defaults to the URL when omitted. `--id` emits only the new link ID. -**`remotelinks remove MON-4818 10001`** — confirmation line only: +**`remotelinks delete MON-4818 10001`** — confirmation line only: ``` -Removed remote link 10001 from MON-4818 +Deleted remote link 10001 from MON-4818 ``` ### `transitions` diff --git a/tools/jtk/internal/cmd/remotelinks/remotelinks.go b/tools/jtk/internal/cmd/remotelinks/remotelinks.go index 87aeba9f..4cd497dd 100644 --- a/tools/jtk/internal/cmd/remotelinks/remotelinks.go +++ b/tools/jtk/internal/cmd/remotelinks/remotelinks.go @@ -28,12 +28,12 @@ func Register(parent *cobra.Command, opts *root.Options) { Use: "remotelinks", Aliases: []string{"remotelink", "rl"}, Short: "Manage issue remote (web) links", - Long: "Commands for listing, adding, and removing an issue's remote (web) links — external URLs shown in the Jira issue links sidebar.", + Long: "Commands for listing, adding, and deleting an issue's remote (web) links — external URLs shown in the Jira issue links sidebar.", } cmd.AddCommand(newListCmd(opts)) cmd.AddCommand(newAddCmd(opts)) - cmd.AddCommand(newRemoveCmd(opts)) + cmd.AddCommand(newDeleteCmd(opts)) parent.AddCommand(cmd) } @@ -165,23 +165,23 @@ func runAdd(ctx context.Context, opts *root.Options, issueKey, url, title, summa return jtkpresent.Emit(opts, jtkpresent.RemoteLinkPresenter{}.PresentAddedDetail(issueKey, link)) } -func newRemoveCmd(opts *root.Options) *cobra.Command { +func newDeleteCmd(opts *root.Options) *cobra.Command { cmd := &cobra.Command{ - Use: "remove ", - Short: "Remove a remote (web) link from an issue", - Long: "Remove a remote (web) link from an issue by its ID. Use 'jtk remotelinks list' to find link IDs.", - Example: ` jtk remotelinks remove PROJ-123 12345 + Use: "delete ", + Short: "Delete a remote (web) link from an issue", + Long: "Delete a remote (web) link from an issue by its ID. Use 'jtk remotelinks list' to find link IDs.", + Example: ` jtk remotelinks delete PROJ-123 12345 jtk remotelinks list PROJ-123 # find link IDs first`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - return runRemove(cmd.Context(), opts, args[0], args[1]) + return runDelete(cmd.Context(), opts, args[0], args[1]) }, } return cmd } -func runRemove(ctx context.Context, opts *root.Options, issueKey, linkIDArg string) error { +func runDelete(ctx context.Context, opts *root.Options, issueKey, linkIDArg string) error { // Remote link IDs are integers; reject typos before the API call so the // user gets a clear message instead of a server-side 404. linkID, err := strconv.Atoi(linkIDArg) @@ -198,5 +198,5 @@ func runRemove(ctx context.Context, opts *root.Options, issueKey, linkIDArg stri return err } - return jtkpresent.Emit(opts, jtkpresent.RemoteLinkPresenter{}.PresentRemoved(linkID, issueKey)) + return jtkpresent.Emit(opts, jtkpresent.RemoteLinkPresenter{}.PresentDeleted(linkID, issueKey)) } diff --git a/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go b/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go index ae18e9c0..8a8556e5 100644 --- a/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go +++ b/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go @@ -227,7 +227,7 @@ func TestRunAdd_IDOnly(t *testing.T) { testutil.Equal(t, stdout.String(), "10012\n") } -func TestRunRemove(t *testing.T) { +func TestRunDelete(t *testing.T) { t.Parallel() 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") @@ -243,19 +243,19 @@ func TestRunRemove(t *testing.T) { opts := &root.Options{Stdout: &stdout, Stderr: &stderr} opts.SetAPIClient(client) - err = runRemove(context.Background(), opts, "PROJ-123", "10001") + err = runDelete(context.Background(), opts, "PROJ-123", "10001") testutil.RequireNoError(t, err) - testutil.Equal(t, stdout.String(), "Removed remote link 10001 from PROJ-123\n") + testutil.Equal(t, stdout.String(), "Deleted remote link 10001 from PROJ-123\n") testutil.Equal(t, stderr.String(), "") } -func TestRunRemove_NonNumericID(t *testing.T) { +func TestRunDelete_NonNumericID(t *testing.T) { t.Parallel() // A non-numeric link ID is rejected before any API call. No client is set // on opts, so reaching the API would panic — proving validation comes first. opts := &root.Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} - err := runRemove(context.Background(), opts, "PROJ-123", "not-a-number") + err := runDelete(context.Background(), opts, "PROJ-123", "not-a-number") testutil.RequireError(t, err) testutil.Contains(t, err.Error(), "invalid link ID") } diff --git a/tools/jtk/internal/present/remotelink.go b/tools/jtk/internal/present/remotelink.go index d932a3f4..780b63b8 100644 --- a/tools/jtk/internal/present/remotelink.go +++ b/tools/jtk/internal/present/remotelink.go @@ -90,13 +90,13 @@ func (RemoteLinkPresenter) PresentAddedDetail(issueKey string, l *api.RemoteLink } } -// PresentRemoved creates a success message for remote link removal. -func (RemoteLinkPresenter) PresentRemoved(linkID int, issueKey string) *present.OutputModel { +// PresentDeleted creates a success message for remote link deletion. +func (RemoteLinkPresenter) PresentDeleted(linkID int, issueKey string) *present.OutputModel { return &present.OutputModel{ Sections: []present.Section{ &present.MessageSection{ Kind: present.MessageSuccess, - Message: fmt.Sprintf("Removed remote link %d from %s", linkID, issueKey), + Message: fmt.Sprintf("Deleted remote link %d from %s", linkID, issueKey), Stream: present.StreamStdout, }, }, diff --git a/tools/jtk/internal/present/remotelink_test.go b/tools/jtk/internal/present/remotelink_test.go index 3c4d6619..af97cc1d 100644 --- a/tools/jtk/internal/present/remotelink_test.go +++ b/tools/jtk/internal/present/remotelink_test.go @@ -176,14 +176,14 @@ func TestRemoteLinkPresenter_PresentAddedDetail_OmitsEmptyOptional(t *testing.T) } } -func TestRemoteLinkPresenter_PresentRemoved(t *testing.T) { +func TestRemoteLinkPresenter_PresentDeleted(t *testing.T) { t.Parallel() - model := RemoteLinkPresenter{}.PresentRemoved(10001, "PROJ-123") + model := RemoteLinkPresenter{}.PresentDeleted(10001, "PROJ-123") msg := model.Sections[0].(*present.MessageSection) if msg.Kind != present.MessageSuccess { t.Errorf("want MessageSuccess, got %v", msg.Kind) } - if msg.Message != "Removed remote link 10001 from PROJ-123" { + if msg.Message != "Deleted remote link 10001 from PROJ-123" { t.Errorf("unexpected message: %q", msg.Message) } } From 65ba54fc7aef464e089be4cd26559def902b76e9 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Wed, 24 Jun 2026 19:54:35 -0400 Subject: [PATCH 5/5] test(jtk): cover remotelinks command surface --- tools/jtk/cmd/jtk/main_test.go | 52 ++++++++++ .../cmd/remotelinks/remotelinks_test.go | 97 +++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/tools/jtk/cmd/jtk/main_test.go b/tools/jtk/cmd/jtk/main_test.go index b92669dc..2bd96b25 100644 --- a/tools/jtk/cmd/jtk/main_test.go +++ b/tools/jtk/cmd/jtk/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "net" "os" "os/exec" @@ -10,6 +11,7 @@ import ( "testing" "github.com/open-cli-collective/atlassian-go/credtest" + "github.com/open-cli-collective/atlassian-go/testutil" ) // unreachableURL returns a URL whose dial fails fast and @@ -93,6 +95,35 @@ func runCLI(t *testing.T, dir string, stdin string, args ...string) (stderr stri return errBuf.String(), cmd.ProcessState.ExitCode() } +func runCLIWithOutput(t *testing.T, dir string, stdin string, args ...string) (stdout string, stderr string, code int) { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable: %v", err) + } + cmd := exec.Command(exe, args...) //nolint:gosec // G204: exe is this test binary + cmd.Env = append(os.Environ(), + entrypointEnv+"=1", + "HOME="+dir, + "XDG_CONFIG_HOME="+dir, + "ATLASSIAN_CLI_KEYRING_BACKEND=file", + "ATLASSIAN_CLI_KEYRING_PASSPHRASE=credtest-passphrase", + "ATLASSIAN_API_TOKEN=", "JIRA_API_TOKEN=", "CFL_API_TOKEN=", + "ATLASSIAN_URL=", "JIRA_URL=", + ) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + runErr := cmd.Run() + if cmd.ProcessState == nil { + t.Fatalf("subprocess did not start: %v", runErr) + } + return outBuf.String(), errBuf.String(), cmd.ProcessState.ExitCode() +} + func writeLegacyShared(t *testing.T, dir, url, token string) string { t.Helper() p := filepath.Join(dir, "atlassian-cli", "config.yml") @@ -213,3 +244,24 @@ func TestEntrypoint_DivergentInit_NoMutationBeforeFailLoud(t *testing.T) { t.Fatalf("divergent init must mutate NOTHING on disk:\n%s", raw) } } + +func TestRun_RemotelinksRegisteredInMain(t *testing.T) { + t.Parallel() + + oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = []string{"jtk", "remotelinks", "--help"} + + err := run(context.Background()) + testutil.RequireNoError(t, err) +} + +func TestRun_RemotelinksRemoveRejected(t *testing.T) { + dir := credtest.Hermetic(t) + stdout, stderr, code := runCLIWithOutput(t, dir, "", "remotelinks", "remove", "PROJ-123", "10001") + if code != 0 { + t.Fatalf("expected remotelinks remove help path to exit 0; got %d\nstdout:\n%s\nstderr:\n%s", code, stdout, stderr) + } + testutil.Contains(t, stdout, "delete") + testutil.NotContains(t, stdout, "\n remove") +} diff --git a/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go b/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go index 8a8556e5..4bb4caf0 100644 --- a/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go +++ b/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/open-cli-collective/atlassian-go/testutil" @@ -31,6 +32,66 @@ func TestNewAddCmd_RequiresURL(t *testing.T) { testutil.NotNil(t, flag) } +func TestRegister_ExecutesCanonicalAndAliasCommands(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + args []string + }{ + {name: "canonical", args: []string{"remotelinks", "list", "PROJ-123", "--id"}}, + {name: "singular-alias", args: []string{"remotelink", "list", "PROJ-123", "--id"}}, + {name: "short-alias", args: []string{"rl", "list", "PROJ-123", "--id"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + server := remoteLinkListServer(t) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + rootCmd, opts := root.NewCmd() + var stdout bytes.Buffer + opts.Stdout = &stdout + opts.Stderr = &bytes.Buffer{} + opts.SetAPIClient(client) + Register(rootCmd, opts) + rootCmd.SetArgs(tc.args) + + err = rootCmd.Execute() + testutil.RequireNoError(t, err) + testutil.Equal(t, stdout.String(), "10001\n") + }) + } +} + +func TestRegister_RemoveVerbRejected(t *testing.T) { + t.Parallel() + + rootCmd, opts := root.NewCmd() + Register(rootCmd, opts) + + cmd, _, err := rootCmd.Find([]string{"remotelinks"}) + testutil.RequireNoError(t, err) + testutil.NotNil(t, cmd) + + subcommands := cmd.Commands() + testutil.Equal(t, len(subcommands), 3) + + var names []string + for _, subcommand := range subcommands { + names = append(names, subcommand.Name()) + } + + joined := "," + strings.Join(names, ",") + "," + testutil.Contains(t, joined, ",list,") + testutil.Contains(t, joined, ",add,") + testutil.Contains(t, joined, ",delete,") + testutil.NotContains(t, joined, ",remove,") +} + func remoteLinkListServer(t *testing.T) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -182,6 +243,42 @@ func TestRunAdd(t *testing.T) { testutil.Equal(t, sent.Object.Title, "Example") } +func TestRunAdd_SummaryAndRelationshipRoundTrip(t *testing.T) { + t.Parallel() + 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": 10013}) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + testutil.RequireNoError(t, err) + + opts := &root.Options{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runAdd( + context.Background(), + opts, + "PROJ-123", + "https://example.com", + "Example", + "Reference docs", + "mentioned in", + ) + testutil.RequireNoError(t, err) + + var sent api.CreateRemoteLinkRequest + err = json.Unmarshal(capturedBody, &sent) + testutil.RequireNoError(t, err) + testutil.Equal(t, sent.Relationship, "mentioned in") + testutil.Equal(t, sent.Object.Summary, "Reference docs") +} + func TestRunAdd_TitleDefaultsToURL(t *testing.T) { t.Parallel() var capturedBody []byte