diff --git a/README.md b/README.md index dc08bdcd..196179ab 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,13 @@ 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. +# 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 delete 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..00a1fb49 100644 --- a/tools/jtk/api/errors.go +++ b/tools/jtk/api/errors.go @@ -17,6 +17,9 @@ 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") + 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 new file mode 100644 index 00000000..c96cc9ae --- /dev/null +++ b/tools/jtk/api/remotelinks.go @@ -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 +// 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 + } + 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 +} diff --git a/tools/jtk/api/remotelinks_test.go b/tools/jtk/api/remotelinks_test.go new file mode 100644 index 00000000..d782a248 --- /dev/null +++ b/tools/jtk/api/remotelinks_test.go @@ -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) +} 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/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/OUTPUT_SPEC.md b/tools/jtk/internal/cmd/OUTPUT_SPEC.md index 374fa28f..15091fd3 100644 --- a/tools/jtk/internal/cmd/OUTPUT_SPEC.md +++ b/tools/jtk/internal/cmd/OUTPUT_SPEC.md @@ -488,6 +488,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 delete MON-4818 10001`** — confirmation line only: +``` +Deleted 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..4cd497dd --- /dev/null +++ b/tools/jtk/internal/cmd/remotelinks/remotelinks.go @@ -0,0 +1,202 @@ +// 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" + "fmt" + "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 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(newDeleteCmd(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 newDeleteCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + 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 runDelete(cmd.Context(), opts, args[0], args[1]) + }, + } + + return cmd +} + +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) + if err != nil { + return fmt.Errorf("invalid link ID %q: must be a number", linkIDArg) + } + + 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{}.PresentDeleted(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..4bb4caf0 --- /dev/null +++ b/tools/jtk/internal/cmd/remotelinks/remotelinks_test.go @@ -0,0 +1,358 @@ +package remotelinks + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "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 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) { + 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_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 + 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 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") + 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 = runDelete(context.Background(), opts, "PROJ-123", "10001") + testutil.RequireNoError(t, err) + testutil.Equal(t, stdout.String(), "Deleted remote link 10001 from PROJ-123\n") + testutil.Equal(t, stderr.String(), "") +} + +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 := 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 new file mode 100644 index 00000000..780b63b8 --- /dev/null +++ b/tools/jtk/internal/present/remotelink.go @@ -0,0 +1,117 @@ +// 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. 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 { + rows := make([]present.Row, len(links)) + for i, l := range links { + 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{section}, + } +} + +// 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}, + }, + } +} + +// 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("Deleted remote link %d 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..af97cc1d --- /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_PresentDeleted(t *testing.T) { + t.Parallel() + 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 != "Deleted 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) + } +}