From 714b13962e30b15c03082049227e4d8c69d52eac Mon Sep 17 00:00:00 2001 From: rori Date: Mon, 2 Nov 2026 17:05:39 -0500 Subject: [PATCH] feat(jtk): add issues rank to re-order issues before or after another `jtk issues rank ... (--before | --after )` moves issues, in the order given, to sit immediately before or after another issue in Jira's rank - the order boards and backlogs show - through the Agile `PUT /rest/agile/1.0/issue/rank` endpoint. - Exactly one of --before / --after (cobra mutually-exclusive + one-required); the anchor may not be among the ranked issues. - Lists over the endpoint's 50-issue cap go in order-preserving chunks (each --after chunk anchors on the previous chunk's last issue). - A 207 Multi-Status names each refused issue in the error (non-zero exit); --id emits the ranked keys. - No prompt: re-ranking loses no data (GUARDRAILS 5.1). Co-Authored-By: Claude Opus 5.5 --- skills/Jira/CliReference.md | 1 + skills/Jira/SKILL.md | 1 + tools/jtk/README.md | 16 +++ tools/jtk/api/rank.go | 87 ++++++++++++ tools/jtk/api/rank_test.go | 157 +++++++++++++++++++++ tools/jtk/internal/cmd/issues/issues.go | 1 + tools/jtk/internal/cmd/issues/rank.go | 77 ++++++++++ tools/jtk/internal/cmd/issues/rank_test.go | 145 +++++++++++++++++++ tools/jtk/internal/present/issue.go | 14 ++ 9 files changed, 499 insertions(+) create mode 100644 tools/jtk/api/rank.go create mode 100644 tools/jtk/api/rank_test.go create mode 100644 tools/jtk/internal/cmd/issues/rank.go create mode 100644 tools/jtk/internal/cmd/issues/rank_test.go diff --git a/skills/Jira/CliReference.md b/skills/Jira/CliReference.md index 7623b7f6..a3128bea 100644 --- a/skills/Jira/CliReference.md +++ b/skills/Jira/CliReference.md @@ -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 | diff --git a/skills/Jira/SKILL.md b/skills/Jira/SKILL.md index cc9bf49c..b9e0f204 100644 --- a/skills/Jira/SKILL.md +++ b/skills/Jira/SKILL.md @@ -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 "..."` | diff --git a/tools/jtk/README.md b/tools/jtk/README.md index a16d8869..01ae4a22 100644 --- a/tools/jtk/README.md +++ b/tools/jtk/README.md @@ -450,6 +450,22 @@ jtk issues assign PROJ-123 --unassign |------|---------|-------------| | `--unassign` | `false` | Remove current assignee | +--- + +### `jtk issues rank ... (--before | --after )` + +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:** - `` - The issue key (**required**) - `[user]` - Account ID, email, display name, or `"me"` (required unless `--unassign`) diff --git a/tools/jtk/api/rank.go b/tools/jtk/api/rank.go new file mode 100644 index 00000000..1d4da427 --- /dev/null +++ b/tools/jtk/api/rank.go @@ -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 +} diff --git a/tools/jtk/api/rank_test.go b/tools/jtk/api/rank_test.go new file mode 100644 index 00000000..462530cb --- /dev/null +++ b/tools/jtk/api/rank_test.go @@ -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) +} diff --git a/tools/jtk/internal/cmd/issues/issues.go b/tools/jtk/internal/cmd/issues/issues.go index fb61a99a..5a300c55 100644 --- a/tools/jtk/internal/cmd/issues/issues.go +++ b/tools/jtk/internal/cmd/issues/issues.go @@ -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)) diff --git a/tools/jtk/internal/cmd/issues/rank.go b/tools/jtk/internal/cmd/issues/rank.go new file mode 100644 index 00000000..e17c0dfd --- /dev/null +++ b/tools/jtk/internal/cmd/issues/rank.go @@ -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 ... (--before | --after )", + 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)) +} diff --git a/tools/jtk/internal/cmd/issues/rank_test.go b/tools/jtk/internal/cmd/issues/rank_test.go new file mode 100644 index 00000000..13b0091a --- /dev/null +++ b/tools/jtk/internal/cmd/issues/rank_test.go @@ -0,0 +1,145 @@ +package issues + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "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" +) + +// rankServer accepts PUT /rest/agile/1.0/issue/rank, records each body and +// answers with status + body. +func rankServer(t *testing.T, calls *[]map[string]any, status int, body string) *httptest.Server { + t.Helper() + var mu sync.Mutex + return httptest.NewServer(http.HandlerFunc(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 + } + raw, _ := io.ReadAll(r.Body) + var c map[string]any + _ = json.Unmarshal(raw, &c) + mu.Lock() + *calls = append(*calls, c) + mu.Unlock() + w.WriteHeader(status) + if body != "" { + _, _ = w.Write([]byte(body)) + } + })) +} + +func rankOpts(t *testing.T, server *httptest.Server) (*root.Options, *bytes.Buffer) { + t.Helper() + 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) + return opts, &stdout +} + +func TestRankCmd_Before(t *testing.T) { + var calls []map[string]any + server := rankServer(t, &calls, http.StatusNoContent, "") + defer server.Close() + opts, stdout := rankOpts(t, server) + + cmd := newRankCmd(opts) + cmd.SetArgs([]string{"PROJ-2", "PROJ-3", "--before", "PROJ-1"}) + testutil.RequireNoError(t, cmd.Execute()) + + testutil.Equal(t, len(calls), 1) + testutil.Equal(t, calls[0]["rankBeforeIssue"], "PROJ-1") + testutil.Contains(t, stdout.String(), "Ranked PROJ-2, PROJ-3 before PROJ-1") +} + +func TestRankCmd_After(t *testing.T) { + var calls []map[string]any + server := rankServer(t, &calls, http.StatusNoContent, "") + defer server.Close() + opts, stdout := rankOpts(t, server) + + cmd := newRankCmd(opts) + cmd.SetArgs([]string{"PROJ-2", "--after", "PROJ-9"}) + testutil.RequireNoError(t, cmd.Execute()) + + testutil.Equal(t, calls[0]["rankAfterIssue"], "PROJ-9") + testutil.Contains(t, stdout.String(), "Ranked PROJ-2 after PROJ-9") +} + +func TestRankCmd_IDOnlyEmitsTheRankedKeys(t *testing.T) { + var calls []map[string]any + server := rankServer(t, &calls, http.StatusNoContent, "") + defer server.Close() + opts, stdout := rankOpts(t, server) + opts.IDOnly = true + + cmd := newRankCmd(opts) + cmd.SetArgs([]string{"PROJ-2", "PROJ-3", "--before", "PROJ-1"}) + testutil.RequireNoError(t, cmd.Execute()) + testutil.Equal(t, stdout.String(), "PROJ-2\nPROJ-3\n") +} + +func TestRankCmd_NeedsExactlyOneOfBeforeAndAfter(t *testing.T) { + var calls []map[string]any + server := rankServer(t, &calls, http.StatusNoContent, "") + defer server.Close() + + for _, args := range [][]string{ + {"PROJ-2"}, + {"PROJ-2", "--before", "PROJ-1", "--after", "PROJ-3"}, + } { + opts, _ := rankOpts(t, server) + cmd := newRankCmd(opts) + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + testutil.Error(t, cmd.Execute()) + } + testutil.Equal(t, len(calls), 0) +} + +func TestRankCmd_RefusesTheAnchorAmongTheIssues(t *testing.T) { + var calls []map[string]any + server := rankServer(t, &calls, http.StatusNoContent, "") + defer server.Close() + opts, _ := rankOpts(t, server) + + cmd := newRankCmd(opts) + cmd.SetArgs([]string{"PROJ-2", "proj-1", "--before", "PROJ-1"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "proj-1") + testutil.Equal(t, len(calls), 0) +} + +func TestRankCmd_ReportsTheIssuesJiraRefused(t *testing.T) { + var calls []map[string]any + server := rankServer(t, &calls, http.StatusMultiStatus, + `{"entries":[{"issueKey":"PROJ-2","status":200},{"issueKey":"PROJ-3","status":400,"errors":["Issue is not on the board"]}]}`) + defer server.Close() + opts, _ := rankOpts(t, server) + + cmd := newRankCmd(opts) + cmd.SetArgs([]string{"PROJ-2", "PROJ-3", "--before", "PROJ-1"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + testutil.Error(t, err) + testutil.Contains(t, err.Error(), "PROJ-3") + testutil.Contains(t, err.Error(), "Issue is not on the board") + testutil.NotContains(t, err.Error(), "PROJ-2") +} diff --git a/tools/jtk/internal/present/issue.go b/tools/jtk/internal/present/issue.go index e245b41a..1ef0804d 100644 --- a/tools/jtk/internal/present/issue.go +++ b/tools/jtk/internal/present/issue.go @@ -613,6 +613,20 @@ func (IssuePresenter) PresentDeleted(key string) *present.OutputModel { // PresentAssigned creates a success message for assignment. // If assignee is empty, presents as unassignment. +// PresentRanked creates a success message for issues ranked before or after +// another issue (position is "before" or "after"). +func (IssuePresenter) PresentRanked(keys []string, position, anchor string) *present.OutputModel { + return &present.OutputModel{ + Sections: []present.Section{ + &present.MessageSection{ + Kind: present.MessageSuccess, + Message: fmt.Sprintf("Ranked %s %s %s", strings.Join(keys, ", "), position, anchor), + Stream: present.StreamStdout, + }, + }, + } +} + func (IssuePresenter) PresentAssigned(key, assignee string) *present.OutputModel { msg := fmt.Sprintf("Unassigned issue %s", key) if assignee != "" {