Skip to content
Closed
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
1 change: 1 addition & 0 deletions skills/Jira/CliReference.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jtk [resource] [action] [KEY/ID] [flags]
| `jtk issues update PROJ-123 --assignee none` | Unassign via update |
| `jtk issues assign PROJ-123 VALUE` | Dedicated assign command; same resolver inputs as `--assignee` (user is a positional arg, not a flag) |
| `jtk issues assign PROJ-123 --unassign` | Unassign via dedicated command (equivalent to `--assignee none` on update) |
| `jtk issues rank PROJ-7 [PROJ-8 ...] --before PROJ-3` | Re-order issues on Jira's rank (the board / backlog order): place them, in the order given, immediately before (`--before`) or after (`--after`) another issue. Exactly one of the two; no prompt (re-ranking loses no data). Lists over 50 go in order-preserving chunks; refused issues are named in the error |
| `jtk issues move PROJ-1 [PROJ-2 ...] --to-project NEWPROJ` | Move one or more issues to another project (Jira Cloud only). By default synchronous — waits for completion. Max 1000 issues per request. See move flags below |
| `jtk issues move-status TASK_ID` | Check status of an async move operation (used with `--no-wait`) |
| `jtk issues delete PROJ-123` | Permanently delete an issue. Interactive `y/N` prompt by default (prompt goes to stderr, reads from stdin); pass `--force` to skip. Destructive and irreversible |
Expand Down
1 change: 1 addition & 0 deletions skills/Jira/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ Use that key directly — no API lookup needed.
| Assign (me / email / display name / account ID) | `jtk issues update PROJ-123 --assignee VALUE` (or equivalently `jtk issues assign PROJ-123 VALUE`) |
| Unassign | `jtk issues update PROJ-123 --assignee none` (or equivalently `jtk issues assign PROJ-123 --unassign`) |
| Transition issue | `jtk transitions list PROJ-123` then `jtk transitions do PROJ-123 "Done"` |
| Re-order the board / backlog | `jtk issues rank PROJ-7 --before PROJ-3` (or `--after`) |
| Current user | `jtk me` (or `jtk me --id` for just the account ID) |
| Current sprint | `jtk sprints current --board ID_OR_NAME` |
| Add comment | `jtk comments add PROJ-123 --body "..."` |
Expand Down
16 changes: 16 additions & 0 deletions tools/jtk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,22 @@ jtk issues assign PROJ-123 --unassign
|------|---------|-------------|
| `--unassign` | `false` | Remove current assignee |

---

### `jtk issues rank <issue-key>... (--before <issue-key> | --after <issue-key>)`

Move one or more issues to sit immediately before or after another issue in Jira's rank — the order boards and backlogs show. The issues keep the order given. Exactly one of `--before` and `--after` is required. Lists longer than the API's 50-issue limit are sent in order-preserving chunks; any issue Jira refuses is named in the error.

```bash
jtk issues rank PROJ-7 --before PROJ-3
jtk issues rank PROJ-7 PROJ-8 PROJ-9 --after PROJ-3
```

| Flag | Default | Description |
|------|---------|-------------|
| `--before` | | Rank the issues immediately before this issue |
| `--after` | | Rank the issues immediately after this issue |

**Arguments:**
- `<issue-key>` - The issue key (**required**)
- `[user]` - Account ID, email, display name, or `"me"` (required unless `--unassign`)
Expand Down
87 changes: 87 additions & 0 deletions tools/jtk/api/rank.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package api //nolint:revive // package name is intentional

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
)

// MaxRankIssues is the most issues the Agile rank endpoint takes in one call.
const MaxRankIssues = 50

// RankFailure is one issue the rank endpoint refused (an entry of a 207
// Multi-Status response).
type RankFailure struct {
Key string
Status int
Errors []string
}

type rankResponse struct {
Entries []struct {
IssueKey string `json:"issueKey"`
Status int `json:"status"`
Errors []string `json:"errors"`
} `json:"entries"`
}

// RankIssues moves issueKeys, in the order given, to sit immediately before
// (before != "") or after (after != "") the anchor issue in Jira's rank - the
// order boards and backlogs show. Exactly one of before and after must be set.
//
// The endpoint (PUT /rest/agile/1.0/issue/rank) takes at most MaxRankIssues
// issues per call, so longer lists go in chunks that keep the given order:
// every "before" chunk is placed immediately before the anchor, so later
// chunks land between the earlier ones and the anchor; each "after" chunk
// anchors on the previous chunk's last issue. Issues Jira refuses in a 207
// response come back as RankFailures; a transport or API error stops at the
// failing chunk and is returned with the failures so far.
func (c *Client) RankIssues(ctx context.Context, issueKeys []string, before, after string) ([]RankFailure, error) {
if (before == "") == (after == "") {
return nil, errors.New("ranking issues: exactly one of before and after is required")
}
urlStr := c.AgileURL + "/issue/rank"
anchorAfter := after
var failures []RankFailure
for start := 0; start < len(issueKeys); start += MaxRankIssues {
chunk := issueKeys[start:min(start+MaxRankIssues, len(issueKeys))]
req := map[string]any{"issues": chunk}
if before != "" {
req["rankBeforeIssue"] = before
} else {
req["rankAfterIssue"] = anchorAfter
}
body, err := c.Put(ctx, urlStr, req)
if err != nil {
return failures, fmt.Errorf("ranking issues: %w", err)
}
refused, err := parseRankFailures(body)
if err != nil {
return failures, err
}
failures = append(failures, refused...)
anchorAfter = chunk[len(chunk)-1]
}
return failures, nil
}

// parseRankFailures reads a 207 body's refused entries. A 204 has no body.
func parseRankFailures(body []byte) ([]RankFailure, error) {
if len(body) == 0 {
return nil, nil
}
var resp rankResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("ranking issues: parsing the response: %w", err)
}
var failures []RankFailure
for _, e := range resp.Entries {
if e.Status == http.StatusOK && len(e.Errors) == 0 {
continue
}
failures = append(failures, RankFailure{Key: e.IssueKey, Status: e.Status, Errors: e.Errors})
}
return failures, nil
}
157 changes: 157 additions & 0 deletions tools/jtk/api/rank_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package api //nolint:revive // package name is intentional

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"testing"

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

// rankCall is one PUT /issue/rank body the stub server received.
type rankCall struct {
Issues []string `json:"issues"`
Before string `json:"rankBeforeIssue"`
After string `json:"rankAfterIssue"`
}

func rankStub(t *testing.T, calls *[]rankCall, respond func(w http.ResponseWriter, c rankCall)) http.HandlerFunc {
t.Helper()
var mu sync.Mutex
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/rest/agile/1.0/issue/rank" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
body, _ := io.ReadAll(r.Body)
var c rankCall
testutil.RequireNoError(t, json.Unmarshal(body, &c))
mu.Lock()
*calls = append(*calls, c)
mu.Unlock()
respond(w, c)
}
}

func noContent(w http.ResponseWriter, _ rankCall) { w.WriteHeader(http.StatusNoContent) }

func keys(prefix string, n int) []string {
out := make([]string, n)
for i := range out {
out[i] = fmt.Sprintf("%s-%d", prefix, i+1)
}
return out
}

func TestRankIssues_Before(t *testing.T) {
t.Parallel()
var calls []rankCall
client, server := newTestClientWithServer(t, rankStub(t, &calls, noContent))
defer server.Close()

failures, err := client.RankIssues(context.Background(), []string{"PROJ-2", "PROJ-3"}, "PROJ-1", "")
testutil.RequireNoError(t, err)
testutil.Equal(t, len(failures), 0)
testutil.Equal(t, len(calls), 1)
testutil.Equal(t, calls[0].Before, "PROJ-1")
testutil.Equal(t, calls[0].After, "")
testutil.Equal(t, fmt.Sprint(calls[0].Issues), "[PROJ-2 PROJ-3]")
}

func TestRankIssues_After(t *testing.T) {
t.Parallel()
var calls []rankCall
client, server := newTestClientWithServer(t, rankStub(t, &calls, noContent))
defer server.Close()

_, err := client.RankIssues(context.Background(), []string{"PROJ-2"}, "", "PROJ-9")
testutil.RequireNoError(t, err)
testutil.Equal(t, len(calls), 1)
testutil.Equal(t, calls[0].After, "PROJ-9")
testutil.Equal(t, calls[0].Before, "")
}

// A 207 Multi-Status lists the issues Jira refused; they come back as
// RankFailures and the issues it ranked are not reported.
func TestRankIssues_MultiStatusReportsTheRefusedIssues(t *testing.T) {
t.Parallel()
var calls []rankCall
client, server := newTestClientWithServer(t, rankStub(t, &calls, func(w http.ResponseWriter, _ rankCall) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMultiStatus)
_, _ = w.Write([]byte(`{"entries":[
{"issueId":10001,"issueKey":"PROJ-2","status":200},
{"issueId":10002,"issueKey":"PROJ-3","status":400,"errors":["Issue is not on the board"]}]}`))
}))
defer server.Close()

failures, err := client.RankIssues(context.Background(), []string{"PROJ-2", "PROJ-3"}, "PROJ-1", "")
testutil.RequireNoError(t, err)
testutil.Equal(t, len(failures), 1)
testutil.Equal(t, failures[0].Key, "PROJ-3")
testutil.Equal(t, failures[0].Status, 400)
testutil.Equal(t, fmt.Sprint(failures[0].Errors), "[Issue is not on the board]")
}

// More than MaxRankIssues go in chunks that keep the given order: every
// "before" chunk lands immediately before the anchor (so later chunks sit
// between the earlier ones and the anchor), and each "after" chunk anchors on
// the previous chunk's last issue.
func TestRankIssues_ChunksKeepTheGivenOrder(t *testing.T) {
t.Parallel()
n := MaxRankIssues + 3
in := keys("PROJ", n)

var calls []rankCall
client, server := newTestClientWithServer(t, rankStub(t, &calls, noContent))
defer server.Close()
_, err := client.RankIssues(context.Background(), in, "ANCHOR-1", "")
testutil.RequireNoError(t, err)
testutil.Equal(t, len(calls), 2)
testutil.Equal(t, len(calls[0].Issues), MaxRankIssues)
testutil.Equal(t, len(calls[1].Issues), 3)
testutil.Equal(t, calls[1].Issues[0], in[MaxRankIssues])
testutil.Equal(t, calls[0].Before, "ANCHOR-1")
testutil.Equal(t, calls[1].Before, "ANCHOR-1")

var afterCalls []rankCall
client2, server2 := newTestClientWithServer(t, rankStub(t, &afterCalls, noContent))
defer server2.Close()
_, err = client2.RankIssues(context.Background(), in, "", "ANCHOR-1")
testutil.RequireNoError(t, err)
testutil.Equal(t, len(afterCalls), 2)
testutil.Equal(t, afterCalls[0].After, "ANCHOR-1")
testutil.Equal(t, afterCalls[1].After, in[MaxRankIssues-1])
}

func TestRankIssues_NeedsExactlyOneAnchor(t *testing.T) {
t.Parallel()
var calls []rankCall
client, server := newTestClientWithServer(t, rankStub(t, &calls, noContent))
defer server.Close()

_, err := client.RankIssues(context.Background(), []string{"PROJ-2"}, "", "")
testutil.Error(t, err)
_, err = client.RankIssues(context.Background(), []string{"PROJ-2"}, "PROJ-1", "PROJ-3")
testutil.Error(t, err)
testutil.Equal(t, len(calls), 0)
}

func TestRankIssues_APIErrorStops(t *testing.T) {
t.Parallel()
var calls []rankCall
client, server := newTestClientWithServer(t, rankStub(t, &calls, func(w http.ResponseWriter, _ rankCall) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"errorMessages":["Rank field is not configured"]}`))
}))
defer server.Close()

_, err := client.RankIssues(context.Background(), keys("PROJ", MaxRankIssues+1), "PROJ-99", "")
testutil.Error(t, err)
testutil.Equal(t, len(calls), 1)
}
1 change: 1 addition & 0 deletions tools/jtk/internal/cmd/issues/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func Register(parent *cobra.Command, opts *root.Options) {
cmd.AddCommand(newUpdateCmd(opts))
cmd.AddCommand(newDeleteCmd(opts))
cmd.AddCommand(newAssignCmd(opts))
cmd.AddCommand(newRankCmd(opts))
cmd.AddCommand(newFieldsCmd(opts))
cmd.AddCommand(newFieldOptionsCmd(opts))
cmd.AddCommand(newTypesCmd(opts))
Expand Down
77 changes: 77 additions & 0 deletions tools/jtk/internal/cmd/issues/rank.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package issues

import (
"context"
"fmt"
"strings"

"github.com/spf13/cobra"

"github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root"
jtkpresent "github.com/open-cli-collective/jira-ticket-cli/internal/present"
)

func newRankCmd(opts *root.Options) *cobra.Command {
var before, after string

cmd := &cobra.Command{
Use: "rank <issue-key>... (--before <issue-key> | --after <issue-key>)",
Short: "Rank issues before or after another issue",
Long: `Move one or more issues to sit immediately before or after another issue in
Jira's rank - the order boards and backlogs show. The issues keep the order
given. Exactly one of --before and --after is required.

Ranking moves issues within the order and loses no data, so there is no prompt.`,
Example: ` # Put PROJ-7 directly above PROJ-3
jtk issues rank PROJ-7 --before PROJ-3

# Put three issues, in this order, directly below PROJ-3
jtk issues rank PROJ-7 PROJ-8 PROJ-9 --after PROJ-3`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runRank(cmd.Context(), opts, args, before, after)
},
}

cmd.Flags().StringVar(&before, "before", "", "Rank the issues immediately before this issue")
cmd.Flags().StringVar(&after, "after", "", "Rank the issues immediately after this issue")
cmd.MarkFlagsMutuallyExclusive("before", "after")
cmd.MarkFlagsOneRequired("before", "after")

return cmd
}

func runRank(ctx context.Context, opts *root.Options, issueKeys []string, before, after string) error {
anchor, position := before, "before"
if after != "" {
anchor, position = after, "after"
}
for _, k := range issueKeys {
if strings.EqualFold(k, anchor) {
return fmt.Errorf("cannot rank %s %s itself", k, position)
}
}

client, err := opts.APIClient()
if err != nil {
return err
}

failures, err := client.RankIssues(ctx, issueKeys, before, after)
if err != nil {
return err
}
if len(failures) > 0 {
lines := make([]string, len(failures))
for i, f := range failures {
lines[i] = fmt.Sprintf("%s (%d): %s", f.Key, f.Status, strings.Join(f.Errors, "; "))
}
return fmt.Errorf("jira did not rank %d of %d issue(s):\n %s",
len(failures), len(issueKeys), strings.Join(lines, "\n "))
}

if opts.EmitIDOnly() {
return jtkpresent.EmitIDs(opts, issueKeys)
}
return jtkpresent.Emit(opts, jtkpresent.IssuePresenter{}.PresentRanked(issueKeys, position, anchor))
}
Loading
Loading