[READ-ONLY] Mirror of https://github.com/openstatusHQ/cli. OpenStatus CLI www.openstatus.dev
cli cli-app synthetic-monitoring
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add interactive wizards for `sr create` and `sr add-update` (#23)

When required flags are missing and stdin is a TTY, launch a step-by-step
wizard using charmbracelet/huh instead of erroring out. Flags still work
for scripting and CI — non-TTY/--json mode returns a clear missing-flags
error.

Wizard features:
- Status page select with URL display (sr create)
- Component multi-select from the selected page (sr create)
- Status report select with unresolved-first filtering (sr add-update)
- Multi-line message input for create, single-line for add-update
- Summary note + confirmation before submission
- Ctrl+C prints "Aborted." and exits 130

Also adds IsStdinTerminal() to internal/cli for proper stdin TTY detection.

+688 -43
+25 -2
go.mod
··· 9 9 buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260323160652-4be687fa490b.1 10 10 connectrpc.com/connect v1.19.1 11 11 github.com/briandowns/spinner v1.23.2 12 + github.com/charmbracelet/huh v1.0.0 12 13 github.com/fatih/color v1.18.0 13 14 github.com/google/go-cmp v0.7.0 14 15 github.com/joho/godotenv v1.5.1 ··· 27 28 require ( 28 29 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect 29 30 buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1 // indirect 31 + github.com/atotto/clipboard v0.1.4 // indirect 32 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 33 + github.com/catppuccin/go v0.3.0 // indirect 34 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect 35 + github.com/charmbracelet/bubbletea v1.3.6 // indirect 36 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 37 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 38 + github.com/charmbracelet/x/ansi v0.9.3 // indirect 39 + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 40 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect 41 + github.com/charmbracelet/x/term v0.2.1 // indirect 30 42 github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 43 + github.com/dustin/go-humanize v1.0.1 // indirect 44 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 31 45 github.com/fsnotify/fsnotify v1.7.0 // indirect 32 46 github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 33 47 github.com/knadh/koanf/maps v0.1.2 // indirect 48 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 34 49 github.com/mattn/go-colorable v0.1.13 // indirect 50 + github.com/mattn/go-localereader v0.0.1 // indirect 35 51 github.com/mattn/go-runewidth v0.0.16 // indirect 36 52 github.com/mitchellh/copystructure v1.2.0 // indirect 53 + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 37 54 github.com/mitchellh/reflectwalk v1.0.2 // indirect 55 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 56 + github.com/muesli/cancelreader v0.2.2 // indirect 57 + github.com/muesli/termenv v0.16.0 // indirect 38 58 github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 // indirect 39 59 github.com/olekukonko/ll v0.0.8 // indirect 40 - github.com/rivo/uniseg v0.2.0 // indirect 60 + github.com/rivo/uniseg v0.4.7 // indirect 41 61 github.com/russross/blackfriday/v2 v2.1.0 // indirect 42 - golang.org/x/sys v0.26.0 // indirect 62 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 63 + golang.org/x/sync v0.15.0 // indirect 64 + golang.org/x/sys v0.33.0 // indirect 65 + golang.org/x/text v0.23.0 // indirect 43 66 google.golang.org/protobuf v1.36.11 // indirect 44 67 gopkg.in/yaml.v3 v3.0.1 // indirect 45 68 )
+69 -3
go.sum
··· 8 8 buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260323160652-4be687fa490b.1/go.mod h1:GRsD/In1AV3/RC92zWVAjBpj57hJIQm7Rph0rLOYYPg= 9 9 connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= 10 10 connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= 11 + github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 12 + github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 13 + github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 14 + github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 15 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 16 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 17 + github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 18 + github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 11 19 github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= 12 20 github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= 21 + github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 22 + github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 23 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= 24 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= 25 + github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= 26 + github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 27 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 28 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 29 + github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= 30 + github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= 31 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 32 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 33 + github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= 34 + github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 35 + github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 36 + github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 37 + github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= 38 + github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= 39 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= 40 + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 41 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 42 + github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 43 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= 44 + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 45 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 46 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 47 + github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= 48 + github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 49 + github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= 50 + github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= 13 51 github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 14 52 github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 53 + github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 54 + github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 15 55 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 56 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 57 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 58 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 59 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 60 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 61 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 18 62 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 19 63 github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 20 64 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= ··· 37 81 github.com/knadh/koanf/v2 v2.1.1/go.mod h1:4mnTRbZCK+ALuBXHZMjDfG9y714L7TykVnZkXbMU3Es= 38 82 github.com/logrusorgru/aurora/v4 v4.0.0 h1:sRjfPpun/63iADiSvGGjgA1cAYegEWMPCJdUpJYn9JA= 39 83 github.com/logrusorgru/aurora/v4 v4.0.0/go.mod h1:lP0iIa2nrnT/qoFXcOZSrZQpJ1o6n2CUf/hyHi2Q4ZQ= 84 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 85 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 40 86 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 41 87 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 42 88 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 43 89 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 44 90 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 91 + github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 92 + github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 45 93 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 46 94 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 47 95 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 48 96 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 97 + github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 98 + github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 49 99 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 50 100 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 101 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 102 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 103 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 104 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 105 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 106 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 51 107 github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 h1:r3FaAI0NZK3hSmtTDrBVREhKULp8oUeqLT5Eyl2mSPo= 52 108 github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= 53 109 github.com/olekukonko/ll v0.0.8 h1:sbGZ1Fx4QxJXEqL/6IG8GEFnYojUSQ45dJVwN2FH2fc= ··· 56 112 github.com/olekukonko/tablewriter v1.0.7/go.mod h1:H428M+HzoUXC6JU2Abj9IT9ooRmdq9CxuDmKMtrOCMs= 57 113 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 58 114 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 59 - github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 60 115 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 116 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 117 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 61 118 github.com/rodaine/table v1.3.0 h1:4/3S3SVkHnVZX91EHFvAMV7K42AnJ0XuymRR2C5HlGE= 62 119 github.com/rodaine/table v1.3.0/go.mod h1:47zRsHar4zw0jgxGxL9YtFfs7EGN6B/TaS+/Dmk4WxU= 63 120 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= ··· 75 132 github.com/urfave/cli-docs/v3 v3.0.0-alpha6/go.mod h1:p7Z4lg8FSTrPB9GTaNyTrK3ygffHZcK3w0cU2VE+mzU= 76 133 github.com/urfave/cli/v3 v3.0.0-alpha9.2 h1:CL8llQj3dGRLVQQzHxS+ZYRLanOuhyK1fXgLKD+qV+Y= 77 134 github.com/urfave/cli/v3 v3.0.0-alpha9.2/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y= 135 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 136 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 137 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 138 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 139 + golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 140 + golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 141 + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 142 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 143 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 - golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 81 - golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 144 + golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 145 + golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 82 146 golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw= 83 147 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 148 + golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 149 + golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 84 150 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 85 151 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 86 152 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+1 -3
internal/cli/confirmation.go
··· 5 5 "fmt" 6 6 "os" 7 7 "strings" 8 - 9 - "github.com/mattn/go-isatty" 10 8 ) 11 9 12 10 var isInteractiveStdin = func() bool { 13 - return isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) 11 + return IsStdinTerminal() 14 12 } 15 13 16 14 func AskForConfirmation(s string) (bool, error) {
+4 -1
internal/cli/output.go
··· 22 22 func IsJSONOutput() bool { return jsonOutput.Load() } 23 23 func IsQuiet() bool { return quietMode.Load() } 24 24 func IsDebug() bool { return debugMode.Load() } 25 - func IsTerminal() bool { return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) } 25 + func IsTerminal() bool { return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) } 26 + func IsStdinTerminal() bool { 27 + return isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) 28 + } 26 29 func IsStderrTerminal() bool { 27 30 return isatty.IsTerminal(os.Stderr.Fd()) || isatty.IsCygwinTerminal(os.Stderr.Fd()) 28 31 }
+39 -11
internal/statusreport/statusreport_add_update.go
··· 5 5 "fmt" 6 6 "net/http" 7 7 "os" 8 + "strings" 8 9 "time" 9 10 10 11 status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" ··· 77 78 Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), 78 79 }, 79 80 &cli.StringFlag{ 80 - Name: "status", 81 - Usage: "New status (investigating, identified, monitoring, resolved)", 82 - Required: true, 81 + Name: "status", 82 + Usage: "New status (investigating, identified, monitoring, resolved)", 83 83 }, 84 84 &cli.StringFlag{ 85 - Name: "message", 86 - Usage: "Message describing what changed", 87 - Required: true, 85 + Name: "message", 86 + Usage: "Message describing what changed", 88 87 }, 89 88 &cli.StringFlag{ 90 89 Name: "date", ··· 100 99 if err != nil { 101 100 return cli.Exit(err.Error(), 1) 102 101 } 103 - reportId := cmd.Args().Get(0) 102 + 103 + inputs := &addUpdateInputs{ 104 + ReportID: cmd.Args().Get(0), 105 + Status: cmd.String("status"), 106 + Message: cmd.String("message"), 107 + Notify: cmd.Bool("notify"), 108 + } 109 + 110 + needsWizard := inputs.ReportID == "" || inputs.Status == "" || 111 + inputs.Message == "" 112 + 113 + if needsWizard { 114 + if output.IsJSONOutput() || !output.IsStdinTerminal() { 115 + var missing []string 116 + if inputs.ReportID == "" { 117 + missing = append(missing, "<report-id>") 118 + } 119 + if inputs.Status == "" { 120 + missing = append(missing, "--status") 121 + } 122 + if inputs.Message == "" { 123 + missing = append(missing, "--message") 124 + } 125 + return cli.Exit(fmt.Sprintf("missing required arguments: %s", strings.Join(missing, ", ")), 1) 126 + } 127 + inputs, err = runAddUpdateWizard(ctx, apiKey, inputs) 128 + if err != nil { 129 + return cli.Exit(err.Error(), 1) 130 + } 131 + } 104 132 105 133 date := cmd.String("date") 106 134 if date == "" { ··· 112 140 err = AddStatusReportUpdate( 113 141 ctx, 114 142 client, 115 - reportId, 116 - cmd.String("status"), 117 - cmd.String("message"), 143 + inputs.ReportID, 144 + inputs.Status, 145 + inputs.Message, 118 146 date, 119 - cmd.Bool("notify"), 147 + inputs.Notify, 120 148 s, 121 149 ) 122 150 if err != nil {
+52 -23
internal/statusreport/statusreport_create.go
··· 62 62 Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), 63 63 }, 64 64 &cli.StringFlag{ 65 - Name: "title", 66 - Usage: "Title of the status report", 67 - Required: true, 65 + Name: "title", 66 + Usage: "Title of the status report", 68 67 }, 69 68 &cli.StringFlag{ 70 - Name: "status", 71 - Usage: "Initial status (investigating, identified, monitoring, resolved)", 72 - Required: true, 69 + Name: "status", 70 + Usage: "Initial status (investigating, identified, monitoring, resolved)", 73 71 }, 74 72 &cli.StringFlag{ 75 - Name: "message", 76 - Usage: "Initial message describing the incident", 77 - Required: true, 73 + Name: "message", 74 + Usage: "Initial message describing the incident", 78 75 }, 79 76 &cli.StringFlag{ 80 - Name: "page-id", 81 - Usage: "Status page ID to associate with this report", 82 - Required: true, 77 + Name: "page-id", 78 + Usage: "Status page ID to associate with this report", 83 79 }, 84 80 &cli.StringFlag{ 85 81 Name: "component-ids", ··· 99 95 if err != nil { 100 96 return cli.Exit(err.Error(), 1) 101 97 } 98 + 99 + inputs := &createInputs{ 100 + PageID: cmd.String("page-id"), 101 + Title: cmd.String("title"), 102 + Status: cmd.String("status"), 103 + Message: cmd.String("message"), 104 + Notify: cmd.Bool("notify"), 105 + } 106 + if ids := cmd.String("component-ids"); ids != "" { 107 + inputs.ComponentIDs = strings.Split(ids, ",") 108 + } 109 + 110 + needsWizard := inputs.Title == "" || inputs.Status == "" || 111 + inputs.Message == "" || inputs.PageID == "" 112 + 113 + if needsWizard { 114 + if output.IsJSONOutput() || !output.IsStdinTerminal() { 115 + var missing []string 116 + if inputs.Title == "" { 117 + missing = append(missing, "--title") 118 + } 119 + if inputs.Status == "" { 120 + missing = append(missing, "--status") 121 + } 122 + if inputs.Message == "" { 123 + missing = append(missing, "--message") 124 + } 125 + if inputs.PageID == "" { 126 + missing = append(missing, "--page-id") 127 + } 128 + return cli.Exit(fmt.Sprintf("missing required flags: %s", strings.Join(missing, ", ")), 1) 129 + } 130 + inputs, err = runCreateWizard(ctx, apiKey, inputs) 131 + if err != nil { 132 + return cli.Exit(err.Error(), 1) 133 + } 134 + } 135 + 102 136 date := cmd.String("date") 103 137 if date == "" { 104 138 date = time.Now().UTC().Format(time.RFC3339) 105 139 } 106 140 107 - var componentIds []string 108 - if ids := cmd.String("component-ids"); ids != "" { 109 - componentIds = strings.Split(ids, ",") 110 - } 111 - 112 141 client := NewStatusReportClient(apiKey) 113 142 s := output.StartSpinner("Creating status report...") 114 143 id, err := CreateStatusReport( 115 144 ctx, 116 145 client, 117 - cmd.String("title"), 118 - cmd.String("status"), 119 - cmd.String("message"), 146 + inputs.Title, 147 + inputs.Status, 148 + inputs.Message, 120 149 date, 121 - cmd.String("page-id"), 122 - componentIds, 123 - cmd.Bool("notify"), 150 + inputs.PageID, 151 + inputs.ComponentIDs, 152 + inputs.Notify, 124 153 ) 125 154 output.StopSpinner(s) 126 155 if err != nil {
+418
internal/statusreport/statusreport_wizard.go
··· 1 + package statusreport 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "os" 8 + "strings" 9 + 10 + status_pagev1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_page/v1" 11 + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/status_page/v1/status_pagev1connect" 12 + status_reportv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/status_report/v1" 13 + "connectrpc.com/connect" 14 + "github.com/charmbracelet/huh" 15 + "github.com/openstatusHQ/cli/internal/api" 16 + output "github.com/openstatusHQ/cli/internal/cli" 17 + ) 18 + 19 + type createInputs struct { 20 + PageID string 21 + PageName string 22 + Title string 23 + Status string 24 + Message string 25 + ComponentIDs []string 26 + componentNames map[string]string // component ID -> display name, for summary 27 + Notify bool 28 + Confirmed bool 29 + } 30 + 31 + type addUpdateInputs struct { 32 + ReportID string 33 + ReportName string 34 + Status string 35 + Message string 36 + Notify bool 37 + Confirmed bool 38 + } 39 + 40 + func notEmpty(fieldName string) func(string) error { 41 + return func(s string) error { 42 + if strings.TrimSpace(s) == "" { 43 + return fmt.Errorf("%s cannot be empty", fieldName) 44 + } 45 + return nil 46 + } 47 + } 48 + 49 + func handleFormError(err error) error { 50 + if errors.Is(err, huh.ErrUserAborted) { 51 + fmt.Fprintln(os.Stderr, "Aborted.") 52 + os.Exit(130) 53 + } 54 + return err 55 + } 56 + 57 + func statusSelectOptions() []huh.Option[string] { 58 + return []huh.Option[string]{ 59 + huh.NewOption("Investigating", "investigating"), 60 + huh.NewOption("Identified", "identified"), 61 + huh.NewOption("Monitoring", "monitoring"), 62 + huh.NewOption("Resolved", "resolved"), 63 + } 64 + } 65 + 66 + func buildSummary(lines [][2]string) string { 67 + var sb strings.Builder 68 + for _, line := range lines { 69 + sb.WriteString(fmt.Sprintf(" %s: %s\n", line[0], line[1])) 70 + } 71 + return sb.String() 72 + } 73 + 74 + // Data-fetching functions 75 + 76 + func fetchStatusPages(ctx context.Context, apiKey string) ([]*status_pagev1.StatusPageSummary, error) { 77 + client := status_pagev1connect.NewStatusPageServiceClient( 78 + api.DefaultHTTPClient, 79 + api.ConnectBaseURL, 80 + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), 81 + connect.WithProtoJSON(), 82 + ) 83 + resp, err := client.ListStatusPages(ctx, &status_pagev1.ListStatusPagesRequest{}) 84 + if err != nil { 85 + return nil, output.FormatError(err, "status-page", "") 86 + } 87 + return resp.GetStatusPages(), nil 88 + } 89 + 90 + func fetchPageComponents(ctx context.Context, apiKey string, pageID string) ([]*status_pagev1.PageComponent, []*status_pagev1.PageComponentGroup, error) { 91 + client := status_pagev1connect.NewStatusPageServiceClient( 92 + api.DefaultHTTPClient, 93 + api.ConnectBaseURL, 94 + connect.WithInterceptors(api.NewAuthInterceptor(apiKey)), 95 + connect.WithProtoJSON(), 96 + ) 97 + req := &status_pagev1.GetStatusPageContentRequest{} 98 + req.SetId(pageID) 99 + resp, err := client.GetStatusPageContent(ctx, req) 100 + if err != nil { 101 + return nil, nil, output.FormatError(err, "status-page", pageID) 102 + } 103 + return resp.GetComponents(), resp.GetGroups(), nil 104 + } 105 + 106 + func fetchStatusReports(ctx context.Context, apiKey string) ([]*status_reportv1.StatusReportSummary, error) { 107 + client := NewStatusReportClient(apiKey) 108 + 109 + // Try unresolved reports first 110 + req := &status_reportv1.ListStatusReportsRequest{} 111 + l := int32(20) 112 + req.SetLimit(l) 113 + req.SetStatuses([]status_reportv1.StatusReportStatus{ 114 + status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_INVESTIGATING, 115 + status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_IDENTIFIED, 116 + status_reportv1.StatusReportStatus_STATUS_REPORT_STATUS_MONITORING, 117 + }) 118 + 119 + resp, err := client.ListStatusReports(ctx, req) 120 + if err != nil { 121 + return nil, output.FormatError(err, "status-report", "") 122 + } 123 + 124 + reports := resp.GetStatusReports() 125 + if len(reports) > 0 { 126 + return reports, nil 127 + } 128 + 129 + // Fall back to all recent reports 130 + reqAll := &status_reportv1.ListStatusReportsRequest{} 131 + reqAll.SetLimit(l) 132 + resp, err = client.ListStatusReports(ctx, reqAll) 133 + if err != nil { 134 + return nil, output.FormatError(err, "status-report", "") 135 + } 136 + return resp.GetStatusReports(), nil 137 + } 138 + 139 + // Wizard: sr create 140 + 141 + func runCreateWizard(ctx context.Context, apiKey string, prefilled *createInputs) (*createInputs, error) { 142 + inputs := *prefilled 143 + 144 + // Fetch status pages 145 + s := output.StartSpinner("Fetching status pages...") 146 + pages, err := fetchStatusPages(ctx, apiKey) 147 + output.StopSpinner(s) 148 + if err != nil { 149 + return nil, err 150 + } 151 + if len(pages) == 0 { 152 + return nil, fmt.Errorf("no status pages found. Create one at https://www.openstatus.dev first, then run this command again") 153 + } 154 + 155 + // Form 1: page select (if needed) 156 + if inputs.PageID == "" { 157 + pageOptions := make([]huh.Option[string], 0, len(pages)) 158 + for _, p := range pages { 159 + label := p.GetTitle() + " (" + statusPageURL(p) + ")" 160 + pageOptions = append(pageOptions, huh.NewOption(label, p.GetId())) 161 + } 162 + 163 + form1 := huh.NewForm( 164 + huh.NewGroup( 165 + huh.NewSelect[string](). 166 + Title("Status page"). 167 + Options(pageOptions...). 168 + Value(&inputs.PageID), 169 + ), 170 + ).WithTheme(huh.ThemeBase()) 171 + 172 + if err := form1.Run(); err != nil { 173 + return nil, handleFormError(err) 174 + } 175 + } 176 + 177 + // Resolve page name for summary 178 + for _, p := range pages { 179 + if p.GetId() == inputs.PageID { 180 + inputs.PageName = p.GetTitle() 181 + break 182 + } 183 + } 184 + 185 + // Fetch components for selected page 186 + s = output.StartSpinner("Fetching page components...") 187 + components, groups, err := fetchPageComponents(ctx, apiKey, inputs.PageID) 188 + output.StopSpinner(s) 189 + if err != nil { 190 + return nil, err 191 + } 192 + 193 + // Build Form 2 fields dynamically 194 + var fields []huh.Field 195 + 196 + // Build component name map for summary display 197 + if len(components) > 0 { 198 + groupMap := make(map[string]string, len(groups)) 199 + for _, g := range groups { 200 + groupMap[g.GetId()] = g.GetName() 201 + } 202 + 203 + inputs.componentNames = make(map[string]string, len(components)) 204 + compOptions := make([]huh.Option[string], 0, len(components)) 205 + for _, c := range components { 206 + label := c.GetName() 207 + if gid := c.GetGroupId(); gid != "" { 208 + if gname, ok := groupMap[gid]; ok { 209 + label += " (" + gname + ")" 210 + } 211 + } 212 + inputs.componentNames[c.GetId()] = label 213 + compOptions = append(compOptions, huh.NewOption(label, c.GetId())) 214 + } 215 + 216 + // Component multi-select (only if not prefilled) 217 + if len(prefilled.ComponentIDs) == 0 { 218 + fields = append(fields, huh.NewMultiSelect[string](). 219 + Title("Components"). 220 + Options(compOptions...). 221 + Value(&inputs.ComponentIDs)) 222 + } 223 + } 224 + 225 + if inputs.Title == "" { 226 + fields = append(fields, huh.NewInput(). 227 + Title("Title"). 228 + Validate(notEmpty("title")). 229 + Value(&inputs.Title)) 230 + } 231 + 232 + if inputs.Status == "" { 233 + fields = append(fields, huh.NewSelect[string](). 234 + Title("Status"). 235 + Options(statusSelectOptions()...). 236 + Value(&inputs.Status)) 237 + } 238 + 239 + if inputs.Message == "" { 240 + fields = append(fields, huh.NewText(). 241 + Title("Message"). 242 + Validate(notEmpty("message")). 243 + Value(&inputs.Message)) 244 + } 245 + 246 + fields = append(fields, huh.NewConfirm(). 247 + Title("Notify subscribers?"). 248 + Value(&inputs.Notify)) 249 + 250 + // Summary + confirm group 251 + summaryNote := huh.NewNote(). 252 + Title("Summary"). 253 + DescriptionFunc(func() string { 254 + lines := [][2]string{ 255 + {"Page", inputs.PageName}, 256 + } 257 + if len(inputs.ComponentIDs) > 0 { 258 + names := make([]string, 0, len(inputs.ComponentIDs)) 259 + for _, id := range inputs.ComponentIDs { 260 + if name, ok := inputs.componentNames[id]; ok { 261 + names = append(names, name) 262 + } else { 263 + names = append(names, id) 264 + } 265 + } 266 + lines = append(lines, [2]string{"Components", strings.Join(names, ", ")}) 267 + } 268 + lines = append(lines, 269 + [2]string{"Title", inputs.Title}, 270 + [2]string{"Status", inputs.Status}, 271 + [2]string{"Message", inputs.Message}, 272 + ) 273 + notifyStr := "no" 274 + if inputs.Notify { 275 + notifyStr = "yes" 276 + } 277 + lines = append(lines, [2]string{"Notify", notifyStr}) 278 + return buildSummary(lines) 279 + }, &inputs) 280 + 281 + form2 := huh.NewForm( 282 + huh.NewGroup(fields...), 283 + huh.NewGroup( 284 + summaryNote, 285 + huh.NewConfirm(). 286 + Title("Create this status report?"). 287 + Value(&inputs.Confirmed), 288 + ), 289 + ).WithTheme(huh.ThemeBase()) 290 + 291 + if err := form2.Run(); err != nil { 292 + return nil, handleFormError(err) 293 + } 294 + 295 + if !inputs.Confirmed { 296 + fmt.Fprintln(os.Stderr, "Aborted.") 297 + os.Exit(130) 298 + } 299 + 300 + return &inputs, nil 301 + } 302 + 303 + // Wizard: sr add-update 304 + 305 + func runAddUpdateWizard(ctx context.Context, apiKey string, prefilled *addUpdateInputs) (*addUpdateInputs, error) { 306 + inputs := *prefilled 307 + 308 + // Fetch reports for selection or name resolution 309 + s := output.StartSpinner("Fetching status reports...") 310 + reports, err := fetchStatusReports(ctx, apiKey) 311 + output.StopSpinner(s) 312 + if err != nil { 313 + return nil, err 314 + } 315 + 316 + if inputs.ReportID == "" { 317 + // Form 1: report select 318 + if len(reports) == 0 { 319 + return nil, fmt.Errorf("no status reports found") 320 + } 321 + 322 + reportOptions := make([]huh.Option[string], 0, len(reports)) 323 + for _, r := range reports { 324 + label := r.GetTitle() + " (" + statusToString(r.GetStatus()) + ")" 325 + reportOptions = append(reportOptions, huh.NewOption(label, r.GetId())) 326 + } 327 + 328 + form1 := huh.NewForm( 329 + huh.NewGroup( 330 + huh.NewSelect[string](). 331 + Title("Select a status report"). 332 + Options(reportOptions...). 333 + Value(&inputs.ReportID), 334 + ), 335 + ).WithTheme(huh.ThemeBase()) 336 + 337 + if err := form1.Run(); err != nil { 338 + return nil, handleFormError(err) 339 + } 340 + } 341 + 342 + // Resolve report name for summary 343 + for _, r := range reports { 344 + if r.GetId() == inputs.ReportID { 345 + inputs.ReportName = r.GetTitle() 346 + break 347 + } 348 + } 349 + if inputs.ReportName == "" { 350 + inputs.ReportName = inputs.ReportID 351 + } 352 + 353 + // Build Form 2 fields dynamically 354 + var fields []huh.Field 355 + 356 + if inputs.Status == "" { 357 + fields = append(fields, huh.NewSelect[string](). 358 + Title("Status"). 359 + Options(statusSelectOptions()...). 360 + Value(&inputs.Status)) 361 + } 362 + 363 + if inputs.Message == "" { 364 + fields = append(fields, huh.NewInput(). 365 + Title("Message"). 366 + Validate(notEmpty("message")). 367 + Value(&inputs.Message)) 368 + } 369 + 370 + fields = append(fields, huh.NewConfirm(). 371 + Title("Notify subscribers?"). 372 + Value(&inputs.Notify)) 373 + 374 + summaryNote := huh.NewNote(). 375 + Title("Summary"). 376 + DescriptionFunc(func() string { 377 + lines := [][2]string{ 378 + {"Report", inputs.ReportName}, 379 + {"Status", inputs.Status}, 380 + {"Message", inputs.Message}, 381 + } 382 + notifyStr := "no" 383 + if inputs.Notify { 384 + notifyStr = "yes" 385 + } 386 + lines = append(lines, [2]string{"Notify", notifyStr}) 387 + return buildSummary(lines) 388 + }, &inputs) 389 + 390 + form2 := huh.NewForm( 391 + huh.NewGroup(fields...), 392 + huh.NewGroup( 393 + summaryNote, 394 + huh.NewConfirm(). 395 + Title("Add this update?"). 396 + Value(&inputs.Confirmed), 397 + ), 398 + ).WithTheme(huh.ThemeBase()) 399 + 400 + if err := form2.Run(); err != nil { 401 + return nil, handleFormError(err) 402 + } 403 + 404 + if !inputs.Confirmed { 405 + fmt.Fprintln(os.Stderr, "Aborted.") 406 + os.Exit(130) 407 + } 408 + 409 + return &inputs, nil 410 + } 411 + 412 + func statusPageURL(p *status_pagev1.StatusPageSummary) string { 413 + if d := p.GetCustomDomain(); d != "" { 414 + d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://") 415 + return "https://" + d 416 + } 417 + return "https://" + p.GetSlug() + ".openstatus.dev" 418 + }
+80
internal/statusreport/statusreport_wizard_test.go
··· 1 + package statusreport 2 + 3 + import ( 4 + "testing" 5 + ) 6 + 7 + func Test_notEmpty(t *testing.T) { 8 + t.Parallel() 9 + 10 + validator := notEmpty("title") 11 + 12 + t.Run("empty string returns error", func(t *testing.T) { 13 + if err := validator(""); err == nil { 14 + t.Error("expected error for empty string") 15 + } 16 + }) 17 + 18 + t.Run("whitespace-only returns error", func(t *testing.T) { 19 + if err := validator(" "); err == nil { 20 + t.Error("expected error for whitespace-only string") 21 + } 22 + }) 23 + 24 + t.Run("non-empty string returns nil", func(t *testing.T) { 25 + if err := validator("hello"); err != nil { 26 + t.Errorf("expected nil, got %v", err) 27 + } 28 + }) 29 + 30 + t.Run("error message contains field name", func(t *testing.T) { 31 + err := validator("") 32 + if err == nil { 33 + t.Fatal("expected error") 34 + } 35 + if err.Error() != "title cannot be empty" { 36 + t.Errorf("expected 'title cannot be empty', got %q", err.Error()) 37 + } 38 + }) 39 + } 40 + 41 + func Test_buildSummary(t *testing.T) { 42 + t.Parallel() 43 + 44 + lines := [][2]string{ 45 + {"Page", "Production"}, 46 + {"Status", "investigating"}, 47 + } 48 + result := buildSummary(lines) 49 + if result == "" { 50 + t.Error("expected non-empty summary") 51 + } 52 + if !contains(result, "Page") || !contains(result, "Production") { 53 + t.Errorf("summary missing expected content: %s", result) 54 + } 55 + if !contains(result, "Status") || !contains(result, "investigating") { 56 + t.Errorf("summary missing expected content: %s", result) 57 + } 58 + } 59 + 60 + func Test_statusSelectOptions(t *testing.T) { 61 + t.Parallel() 62 + 63 + opts := statusSelectOptions() 64 + if len(opts) != 4 { 65 + t.Errorf("expected 4 options, got %d", len(opts)) 66 + } 67 + } 68 + 69 + func contains(s, substr string) bool { 70 + return len(s) >= len(substr) && searchString(s, substr) 71 + } 72 + 73 + func searchString(s, substr string) bool { 74 + for i := 0; i <= len(s)-len(substr); i++ { 75 + if s[i:i+len(substr)] == substr { 76 + return true 77 + } 78 + } 79 + return false 80 + }