From 2c8a009b4e9f7728f5ca32e712a65d43ea13a487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Corn=C3=A9=20Dorrestijn?= Date: Thu, 12 Nov 2026 11:07:33 +0100 Subject: [PATCH] fix(LAN-0): open the move form when jira refuses a move A workflow validator that wants a comment (not a FieldRequiredValidator) went unnoticed, so the board posted the bare move and only toasted Jira's 400. The transition endpoint now answers 422 with Jira's reason, and the board opens the move's form with that reason on it. --- internal/jira/jira.go | 14 ++++++++++++++ internal/web/api_core.go | 5 +++++ internal/web/api_edit_test.go | 25 +++++++++++++++++++++++++ internal/web/static/js/views/board.js | 5 ++++- 4 files changed, 48 insertions(+), 1 deletion(-) 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).