Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
monit-reviewer marked this conversation as resolved.
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"
Expand Down
3 changes: 3 additions & 0 deletions tools/jtk/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions tools/jtk/api/remotelinks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package api //nolint:revive // package name is intentional

import (
"context"
"encoding/json"
"fmt"
"net/url"
)

// RemoteLink represents a Jira issue remote link (a "Web Link" in the Jira UI):
// an external URL attached to an issue and shown in the issue's links sidebar.
// See POST/GET /rest/api/3/issue/{issueIdOrKey}/remotelink.
type RemoteLink struct {
ID int `json:"id"`
Self string `json:"self,omitempty"`
GlobalID string `json:"globalId,omitempty"`
Relationship string `json:"relationship,omitempty"`
Application *RemoteLinkApp `json:"application,omitempty"`
Object RemoteLinkObject `json:"object"`
}

// RemoteLinkApp identifies the application a remote link belongs to.
type RemoteLinkApp struct {
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
}

// RemoteLinkObject holds the external resource a remote link points at.
type RemoteLinkObject struct {
URL string `json:"url"`
Title string `json:"title"`
Summary string `json:"summary,omitempty"`
}

// CreateRemoteLinkRequest is the body for creating/updating a remote link.
type CreateRemoteLinkRequest struct {
GlobalID string `json:"globalId,omitempty"`
Relationship string `json:"relationship,omitempty"`
Object RemoteLinkObject `json:"object"`
}

// remoteLinkCreateResponse is the slim response Jira returns from create:
// it identifies the link but does not echo back the full object.
type remoteLinkCreateResponse struct {
ID int `json:"id"`
Self string `json:"self"`
}

// GetRemoteLinks returns the remote (web) links on an issue.
func (c *Client) GetRemoteLinks(ctx context.Context, issueKey string) ([]RemoteLink, error) {
if issueKey == "" {
return nil, ErrIssueKeyRequired
}

urlStr := fmt.Sprintf("%s/issue/%s/remotelink", c.BaseURL, url.PathEscape(issueKey))
body, err := c.Get(ctx, urlStr)
if err != nil {
return nil, fmt.Errorf("fetching remote links: %w", err)
}

var links []RemoteLink
if err := json.Unmarshal(body, &links); err != nil {
return nil, fmt.Errorf("parsing remote links: %w", err)
}

return links, nil
}

// AddRemoteLink creates a remote (web) link on an issue pointing at url with
// the given title. Jira's create response is slim (id + self only), so the
Comment thread
monit-reviewer marked this conversation as resolved.
// returned RemoteLink echoes back the input object alongside the new ID.
func (c *Client) AddRemoteLink(ctx context.Context, issueKey string, req CreateRemoteLinkRequest) (*RemoteLink, error) {
if issueKey == "" {
return nil, ErrIssueKeyRequired
}
if req.Object.URL == "" {
return nil, ErrRemoteLinkURLRequired
}
Comment thread
monit-reviewer marked this conversation as resolved.
if req.Object.Title == "" {
return nil, ErrRemoteLinkTitleRequired
}

urlStr := fmt.Sprintf("%s/issue/%s/remotelink", c.BaseURL, url.PathEscape(issueKey))
body, err := c.Post(ctx, urlStr, req)
if err != nil {
return nil, fmt.Errorf("adding remote link: %w", err)
}

var resp remoteLinkCreateResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("parsing remote link: %w", err)
}

return &RemoteLink{
ID: resp.ID,
Self: resp.Self,
GlobalID: req.GlobalID,
Relationship: req.Relationship,
Object: req.Object,
}, nil
}

// DeleteRemoteLink deletes a remote link from an issue by its link ID. linkID
// is an int to match the RemoteLink.ID domain type returned by AddRemoteLink
// and GetRemoteLinks, so a list-then-delete flow needs no string conversion.
func (c *Client) DeleteRemoteLink(ctx context.Context, issueKey string, linkID int) error {
if issueKey == "" {
return ErrIssueKeyRequired
}
if linkID <= 0 {
return ErrRemoteLinkIDRequired
}

urlStr := fmt.Sprintf("%s/issue/%s/remotelink/%d", c.BaseURL, url.PathEscape(issueKey), linkID)
if _, err := c.Delete(ctx, urlStr); err != nil {
return fmt.Errorf("deleting remote link %d: %w", linkID, err)
}
return nil
}
126 changes: 126 additions & 0 deletions tools/jtk/api/remotelinks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package api

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/open-cli-collective/atlassian-go/testutil"
)

func TestGetRemoteLinks(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
testutil.Equal(t, r.URL.Path, "/rest/api/3/issue/PROJ-123/remotelink")
testutil.Equal(t, r.Method, http.MethodGet)

_ = json.NewEncoder(w).Encode([]map[string]any{
{
"id": 10001,
"self": "https://acme.atlassian.net/rest/api/3/issue/PROJ-123/remotelink/10001",
"relationship": "mentioned in",
"object": map[string]any{
"url": "https://github.com/owner/repo/issues/456",
"title": "GitHub #456",
"summary": "Some issue",
},
},
})
}))
defer server.Close()

client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"})
testutil.RequireNoError(t, err)

links, err := client.GetRemoteLinks(context.Background(), "PROJ-123")
testutil.RequireNoError(t, err)
testutil.Len(t, links, 1)
testutil.Equal(t, links[0].ID, 10001)
testutil.Equal(t, links[0].Object.URL, "https://github.com/owner/repo/issues/456")
testutil.Equal(t, links[0].Object.Title, "GitHub #456")
testutil.Equal(t, links[0].Relationship, "mentioned in")
}

func TestGetRemoteLinks_EmptyKey(t *testing.T) {
_, err := (&Client{}).GetRemoteLinks(context.Background(), "")
testutil.Equal(t, err, ErrIssueKeyRequired)
}

func TestAddRemoteLink(t *testing.T) {
var capturedBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
testutil.Equal(t, r.URL.Path, "/rest/api/3/issue/PROJ-123/remotelink")
testutil.Equal(t, r.Method, http.MethodPost)
capturedBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 10010,
"self": "https://acme.atlassian.net/rest/api/3/issue/PROJ-123/remotelink/10010",
})
}))
defer server.Close()

client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"})
testutil.RequireNoError(t, err)

req := CreateRemoteLinkRequest{
Object: RemoteLinkObject{
URL: "https://example.com/page",
Title: "Example",
},
}
link, err := client.AddRemoteLink(context.Background(), "PROJ-123", req)
testutil.RequireNoError(t, err)
// The create response is slim; the returned link echoes the request object.
testutil.Equal(t, link.ID, 10010)
testutil.Equal(t, link.Object.URL, "https://example.com/page")
testutil.Equal(t, link.Object.Title, "Example")

var sent CreateRemoteLinkRequest
err = json.Unmarshal(capturedBody, &sent)
testutil.RequireNoError(t, err)
testutil.Equal(t, sent.Object.URL, "https://example.com/page")
testutil.Equal(t, sent.Object.Title, "Example")
}

func TestAddRemoteLink_EmptyKey(t *testing.T) {
_, err := (&Client{}).AddRemoteLink(context.Background(), "", CreateRemoteLinkRequest{
Object: RemoteLinkObject{URL: "https://example.com"},
})
testutil.Equal(t, err, ErrIssueKeyRequired)
}

func TestAddRemoteLink_EmptyURL(t *testing.T) {
_, err := (&Client{}).AddRemoteLink(context.Background(), "PROJ-123", CreateRemoteLinkRequest{})
testutil.Equal(t, err, ErrRemoteLinkURLRequired)
}

func TestAddRemoteLink_EmptyTitle(t *testing.T) {
_, err := (&Client{}).AddRemoteLink(context.Background(), "PROJ-123", CreateRemoteLinkRequest{
Object: RemoteLinkObject{URL: "https://example.com"},
})
testutil.Equal(t, err, ErrRemoteLinkTitleRequired)
}

func TestDeleteRemoteLink(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
testutil.Equal(t, r.URL.Path, "/rest/api/3/issue/PROJ-123/remotelink/10001")
testutil.Equal(t, r.Method, http.MethodDelete)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"})
testutil.RequireNoError(t, err)

err = client.DeleteRemoteLink(context.Background(), "PROJ-123", 10001)
testutil.RequireNoError(t, err)
}

func TestDeleteRemoteLink_EmptyArgs(t *testing.T) {
testutil.Equal(t, (&Client{}).DeleteRemoteLink(context.Background(), "", 10001), ErrIssueKeyRequired)
testutil.Equal(t, (&Client{}).DeleteRemoteLink(context.Background(), "PROJ-123", 0), ErrRemoteLinkIDRequired)
testutil.Equal(t, (&Client{}).DeleteRemoteLink(context.Background(), "PROJ-123", -1), ErrRemoteLinkIDRequired)
}
2 changes: 2 additions & 0 deletions tools/jtk/cmd/jtk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions tools/jtk/cmd/jtk/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"context"
"net"
"os"
"os/exec"
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
}
Loading
Loading