diff --git a/internal/jira/jira.go b/internal/jira/jira.go index 828ba967..d21afceb 100644 --- a/internal/jira/jira.go +++ b/internal/jira/jira.go @@ -823,6 +823,20 @@ type RequestError struct { func (e *RequestError) Error() string { return e.msg } +// Refusal is Jira's reason for turning down a request it understood (400), +// such as a workflow validator refusing a move: its own messages, or the +// whole error without them. ok is false for any other error. +func Refusal(err error) (reason string, ok bool) { + re, isRe := errors.AsType[*RequestError](err) + if !isRe || re.code != http.StatusBadRequest { + return "", false + } + if len(re.Messages) == 0 { + return re.msg, true + } + return strings.Join(re.Messages, "; "), true +} + // jiraMessages reads Jira's error body, {"errorMessages": [...], "errors": // {field: message}}, as "message; field: message", "" for another body. func jiraMessages(body []byte) string { diff --git a/internal/web/api_core.go b/internal/web/api_core.go index 0ff66d0a..15800349 100644 --- a/internal/web/api_core.go +++ b/internal/web/api_core.go @@ -51,6 +51,11 @@ func init() { key := r.PathValue("key") err = s.Client().DoTransition(ctx, key, b.ID) s.Client().Invalidate(key) + // A move the workflow turns down (a validator wanting a comment the + // rules can't tell) is 422 with Jira's reason: the board opens its form. + if reason, ok := jira.Refusal(err); ok { + return nil, httpError{http.StatusUnprocessableEntity, reason} + } return nil, err }) post("/issues/{key}/rank", func(ctx context.Context, s *Server, r *http.Request) (any, error) { diff --git a/internal/web/api_edit_test.go b/internal/web/api_edit_test.go index 1ee88561..812f7bbb 100644 --- a/internal/web/api_edit_test.go +++ b/internal/web/api_edit_test.go @@ -331,3 +331,28 @@ func TestTransitionWithMentions(t *testing.T) { t.Errorf("update = %s", b) } } + +// TestTransitionRefused: a move Jira's workflow turns down (a validator that +// wants a comment the rules didn't show) answers 422 with Jira's reason, so +// the board opens the move's form instead of only reporting the failure. +func TestTransitionRefused(t *testing.T) { + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/transitions") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"errorMessages":["Add a comment, or comment first and then move the work item (within 10 minutes)."],"errors":{}}`)) + return + } + http.NotFound(w, r) + })) + t.Cleanup(fake.Close) + ts := issueServer(t, fake.URL) + var out struct{ Error string } + code := issueCall(t, "POST", ts.URL+"/api/issues/DEMO-1/transition", map[string]any{"ID": "13"}, &out) + if code != http.StatusUnprocessableEntity { + t.Fatalf("transition: %d, want 422", code) + } + if want := "Add a comment, or comment first and then move the work item (within 10 minutes)."; out.Error != want { + t.Errorf("error = %q, want %q", out.Error, want) + } +} diff --git a/internal/web/static/js/views/board.js b/internal/web/static/js/views/board.js index c6b02220..aa8a16e9 100644 --- a/internal/web/static/js/views/board.js +++ b/internal/web/static/js/views/board.js @@ -1076,7 +1076,10 @@ export default function mount(el, { app, params, query, scope, context, toolbar, bus.emit('issue:changed', { key: card.Key, what: card.Key + ' → ' + t.Name }); if (done) confetti(app, [...main.querySelectorAll('.bd-lane-head.kdone')].find(e => e.querySelector('.bd-lane-name').textContent === col.Name)); } catch (e) { - revertCard(card.Key, before); ui.toast(T('Could not move %s: %s', card.Key, e.message), { kind: 'err' }); + revertCard(card.Key, before); + // Jira refused the bare move (422: a validator wants a comment the rules didn't show): open its form with Jira's reason. + if (e.status === 422 && meta && meta.Fields && meta.Fields.length) return import('./fields.js').then(m => m.moveForm(app, card.Key, { ...meta, Message: e.message })); + ui.toast(T('Could not move %s: %s', card.Key, e.message), { kind: 'err' }); } } // A drop into another swimlane gives the card that band's assignee or epic, as Jira's board (TUI jiraSetBand).