diff options
Diffstat (limited to 'app')
2375 files changed, 27116 insertions, 15004 deletions
diff --git a/app/views/shared/icons/_dev_ops_report_no_data.svg b/app/assets/images/dev_ops_report_no_data.svg index 5de929859ae..5de929859ae 100644 --- a/app/views/shared/icons/_dev_ops_report_no_data.svg +++ b/app/assets/images/dev_ops_report_no_data.svg diff --git a/app/assets/images/learn_gitlab/get_started.svg b/app/assets/images/learn_gitlab/get_started.svg new file mode 100644 index 00000000000..0e682842b1f --- /dev/null +++ b/app/assets/images/learn_gitlab/get_started.svg @@ -0,0 +1 @@ +<svg width="468" height="96" xmlns="http://www.w3.org/2000/svg"><g transform="translate(4)" fill="none" fill-rule="evenodd"><path d="M19.817 6.79c.398-1.258 1.516-2.107 2.776-2.107h1.634c1.26 0 2.378.849 2.776 2.107l1.537 4.858c1.088.35 2.13.807 3.117 1.36l4.35-2.29c1.127-.593 2.487-.36 3.378.577l1.156 1.217c.89.938 1.111 2.37.548 3.557l-2.175 4.582c.525 1.038.96 2.136 1.291 3.281l4.613 1.62c1.195.419 2.001 1.597 2.001 2.923v1.721c0 1.326-.806 2.504-2 2.923l-4.614 1.62a18.947 18.947 0 01-1.291 3.281l2.175 4.582c.563 1.186.342 2.619-.548 3.557l-1.156 1.217c-.89.938-2.251 1.17-3.378.577l-4.35-2.29a16.947 16.947 0 01-3.117 1.36l-1.537 4.858c-.398 1.258-1.517 2.107-2.776 2.107h-1.634c-1.26 0-2.378-.849-2.776-2.107l-1.538-4.858a16.973 16.973 0 01-3.116-1.36l-4.35 2.29c-1.127.593-2.488.36-3.379-.577L6.28 46.159c-.89-.938-1.112-2.37-.548-3.557l2.175-4.582a18.93 18.93 0 01-1.292-3.281l-4.613-1.62C.806 32.7 0 31.522 0 30.196v-1.721c0-1.326.806-2.504 2-2.923l4.614-1.62a18.932 18.932 0 011.292-3.281L5.73 16.069c-.564-1.186-.343-2.62.548-3.557l1.155-1.217c.89-.938 2.252-1.17 3.378-.577l4.35 2.29a16.975 16.975 0 013.117-1.36l1.538-4.858zm3.593 34.872c6.464 0 11.705-5.52 11.705-12.327S29.874 17.01 23.41 17.01c-6.465 0-11.705 5.519-11.705 12.326 0 6.808 5.24 12.327 11.705 12.327z" stroke="#6E49CB" stroke-width="2" fill="#EFEDF8"/><path d="M23.41 37.039c4.04 0 7.315-3.45 7.315-7.704 0-4.255-3.275-7.704-7.315-7.704-4.04 0-7.316 3.45-7.316 7.704 0 4.255 3.276 7.704 7.316 7.704z" stroke="#6E49CB" stroke-linecap="round"/><path d="M218.894 5.854c1.177 0 2.186.207 2.858.497.337.145.536.288.633.388a.555.555 0 01.034.037v44.362c-.008.01-.019.023-.034.038-.097.099-.296.242-.633.388-.672.289-1.681.497-2.858.497-1.176 0-2.186-.208-2.857-.497-.338-.146-.537-.289-.634-.388-.015-.015-.026-.028-.034-.038V6.776a.554.554 0 01.034-.037c.097-.1.296-.243.634-.388.671-.29 1.681-.497 2.857-.497z" stroke="#6E49CB" stroke-width="2" fill="#EFEDF8" fill-rule="nonzero"/><path d="M223.59 6.665l30.511 9.4c2.93.902 2.983 2.492.135 3.545l-30.647 11.33V6.664z" fill="#6E49CB"/><path d="M434.38 42.624h-.119l-.11.046c-2.675 1.14-5.224 1.423-7.758.61l-.283-.09-.241.176a4.655 4.655 0 01-6.045-.465v0l-3.178-3.179a4.659 4.659 0 01-.159-6.422l.206-.227-.069-.298c-.496-2.14-.302-4.32.467-6.575l.183-.533-.526-.202-4.709-1.818a3.605 3.605 0 01-2.195-2.467 3.622 3.622 0 01.73-3.221s0 0 0 0l6.166-7.35v0a3.612 3.612 0 014.064-1.047h.001l6.025 2.326.361.14.27-.28c.577-.594 1.167-1.193 1.772-1.798 5.89-5.893 13.552-9.022 23.03-9.36.258-.01.515-.004.773.016v0a6.906 6.906 0 016.359 7.412c-.692 9.033-3.963 16.452-9.804 22.292v0c-.413.414-.826.826-1.242 1.237l-.272.269.137.357 2.433 6.302s0 0 0 0a3.6 3.6 0 01.127 2.2 3.603 3.603 0 01-1.175 1.865h0l-7.347 6.168.376.448-.376-.448a3.613 3.613 0 01-3.221.73 3.614 3.614 0 01-2.466-2.196v0l-1.638-4.244-.144-.374h-.403zm-17.378-19.038l.391.15.268-.32 7.795-9.291.526-.628-.764-.295-5.11-1.972v0a1.785 1.785 0 00-2.008.517v0l-6.166 7.35v0a1.789 1.789 0 00-.36 1.592 1.78 1.78 0 001.085 1.22v0l4.343 1.677zm19.584 18.482v.109l.04.102 1.676 4.345v0c.103.265.266.501.478.69l.389-.437-.389.437a1.78 1.78 0 002.332.034l7.348-6.167v0c.285-.24.488-.561.58-.922l-.566-.146.567.146c.093-.36.071-.74-.062-1.087h-.001l-1.972-5.111-.294-.764-.628.526-9.289 7.796-.21.175v.274zm17.83-39.285l-.22.542.22-.542a5.079 5.079 0 00-2.089-.369h0c-8.997.321-16.228 3.251-21.803 8.827-6.376 6.377-10.292 11.317-11.722 15.569-.723 2.148-.817 4.138-.263 6.047.553 1.901 1.732 3.67 3.47 5.41 1.676 1.675 3.328 2.828 5.103 3.357 1.788.532 3.639.412 5.67-.351 4.005-1.504 8.816-5.55 15.517-12.253 5.54-5.54 8.618-12.556 9.273-21.141a5.076 5.076 0 00-3.156-5.096z" stroke="#6E49CB" fill="#6E49CB" fill-rule="nonzero"/><path d="M440.596 20.006a2.998 2.998 0 004.26.023 2.99 2.99 0 00.877-2.135 3.003 3.003 0 00-.901-2.126 2.996 2.996 0 10-4.237 4.238zm-2.12 2.12a6.001 6.001 0 01-1.3-6.532 5.98 5.98 0 013.244-3.245 5.99 5.99 0 016.53 1.3 5.992 5.992 0 01-8.473 8.475l-.001.001z" fill="#C2B7E6" fill-rule="nonzero"/><path d="M412.525 39.607a1.498 1.498 0 010 2.118l-8.473 8.475a1.502 1.502 0 01-2.137.02 1.513 1.513 0 01-.328-.493 1.49 1.49 0 01.345-1.645l8.475-8.476a1.501 1.501 0 011.632-.325c.183.075.348.186.486.325zm7.415 7.416a1.498 1.498 0 010 2.118l-6.356 6.357a1.501 1.501 0 01-1.631.325 1.495 1.495 0 01-.811-1.958c.075-.181.185-.347.324-.486l6.356-6.356a1.5 1.5 0 012.118 0z" fill="#E0DBF2"/><path d="M416.232 43.314a1.498 1.498 0 010 2.12l-11.65 11.654a1.497 1.497 0 01-2.558-1.06c0-.398.158-.779.439-1.06l11.652-11.653a1.497 1.497 0 012.117 0z" fill="#C2B7E6"/><path d="M7.468 84.21h1.474c-.022-.644-.147-1.196-.377-1.655a3.111 3.111 0 00-.917-1.163 3.704 3.704 0 00-1.344-.672 6.253 6.253 0 00-1.671-.213c-.536 0-1.06.07-1.573.213a4.175 4.175 0 00-1.36.622c-.394.274-.71.629-.951 1.066-.24.426-.36.934-.36 1.524 0 .535.103.984.31 1.344.22.35.503.64.853.869.36.218.765.399 1.213.54.447.132.9.252 1.36.361.47.099.928.197 1.376.295.448.099.847.23 1.196.394.36.153.645.355.853.606.218.251.327.58.327.983 0 .427-.087.776-.262 1.05a1.98 1.98 0 01-.688.655 3.252 3.252 0 01-.967.328c-.35.065-.7.098-1.049.098-.437 0-.863-.054-1.278-.164a3.269 3.269 0 01-1.098-.508 2.762 2.762 0 01-.754-.868c-.185-.361-.278-.787-.278-1.279H.028c0 .71.126 1.328.377 1.852.262.514.612.94 1.049 1.279a4.79 4.79 0 001.54.737 6.948 6.948 0 003.474.05 4.641 4.641 0 001.475-.59 3.472 3.472 0 001.065-1.082c.284-.448.426-.984.426-1.607 0-.579-.11-1.06-.328-1.442a2.736 2.736 0 00-.852-.95 4.215 4.215 0 00-1.196-.59 14.217 14.217 0 00-1.377-.394c-.458-.11-.912-.208-1.36-.295a7.987 7.987 0 01-1.212-.36 2.535 2.535 0 01-.852-.542c-.208-.229-.312-.524-.312-.885 0-.382.071-.699.213-.95.153-.263.35-.47.59-.623a2.66 2.66 0 01.852-.328c.317-.065.64-.098.967-.098.808 0 1.47.19 1.983.573.524.372.83.978.918 1.82zm9.511 3.23h-4.867a2.82 2.82 0 01.213-.918c.12-.295.284-.552.492-.77.207-.22.453-.388.737-.509.295-.13.623-.196.983-.196.35 0 .667.065.95.196.296.12.547.29.755.508.218.208.387.46.508.754.13.295.207.607.229.935zm1.344 2.36h-1.377c-.12.557-.371.972-.754 1.245-.37.274-.852.41-1.442.41-.458 0-.857-.076-1.196-.23a2.394 2.394 0 01-.836-.606 2.483 2.483 0 01-.475-.885 3.438 3.438 0 01-.13-1.065h6.34a6.499 6.499 0 00-.147-1.623 4.48 4.48 0 00-.622-1.573 3.533 3.533 0 00-1.197-1.18c-.491-.317-1.114-.476-1.868-.476-.579 0-1.114.11-1.606.328-.48.219-.9.525-1.261.918-.35.393-.623.858-.82 1.393a5.1 5.1 0 00-.295 1.77c.022.645.115 1.24.279 1.787.175.546.431 1.016.77 1.41.339.393.754.699 1.246.917.502.219 1.092.328 1.77.328.96 0 1.758-.24 2.392-.721s1.043-1.197 1.229-2.147zm3.508-5.786v-2.54h-1.393v2.54h-1.442v1.23h1.442v5.392c0 .393.039.71.115.95.077.24.191.426.344.558.164.13.372.224.623.278.262.044.573.066.934.066h1.065v-1.23h-.639a7.31 7.31 0 01-.54-.016.742.742 0 01-.312-.115.446.446 0 01-.164-.229 1.883 1.883 0 01-.033-.393v-5.262h1.688v-1.229h-1.688zm14.81 8.474v-8.474h-1.393V88.8c0 .382-.055.738-.164 1.065a2.31 2.31 0 01-.459.836c-.207.24-.47.426-.786.558-.306.13-.672.196-1.098.196-.535 0-.956-.153-1.262-.459-.306-.306-.459-.72-.459-1.245v-5.737h-1.393v5.573c0 .459.044.88.131 1.262.099.371.263.694.492.967.23.273.53.486.901.639.372.142.836.213 1.393.213.623 0 1.164-.12 1.622-.36.46-.252.836-.64 1.131-1.164h.033v1.344h1.31zm2.141-8.474v11.703h1.393v-4.36h.033c.153.251.338.464.557.64.23.163.47.294.72.392.252.099.503.17.755.214.262.043.497.065.704.065.645 0 1.207-.115 1.688-.344.492-.23.896-.541 1.213-.934a3.92 3.92 0 00.72-1.41c.165-.535.246-1.104.246-1.704s-.081-1.17-.245-1.705a4.132 4.132 0 00-.738-1.41 3.35 3.35 0 00-1.212-.983c-.481-.24-1.05-.36-1.705-.36-.59 0-1.13.109-1.622.327a2.05 2.05 0 00-1.081 1.016h-.033v-1.147h-1.393zm6.555 4.163c0 .415-.044.82-.131 1.213a3.21 3.21 0 01-.426 1.049 2.22 2.22 0 01-.787.738c-.317.185-.716.278-1.196.278-.481 0-.89-.087-1.23-.262a2.55 2.55 0 01-.835-.721 3.082 3.082 0 01-.459-1.016 4.983 4.983 0 01-.147-1.213c0-.394.043-.781.13-1.164.1-.382.252-.721.46-1.016.207-.306.475-.552.803-.738.327-.186.726-.278 1.196-.278.448 0 .836.087 1.163.262.339.175.612.41.82.705a2.9 2.9 0 01.475 1.016c.109.371.164.754.164 1.147zm129.786-1.786v-4.295h3.048c.885 0 1.53.186 1.934.558.415.36.622.89.622 1.59 0 .699-.207 1.234-.622 1.606-.404.371-1.05.552-1.934.54h-3.048zm-1.557-5.606v11.703h1.557v-4.786h3.572c1.18.01 2.071-.29 2.671-.902.613-.612.918-1.464.918-2.556 0-1.093-.305-1.94-.918-2.541-.6-.612-1.49-.918-2.67-.918h-5.13zm10.478 0v11.703h1.393V80.785h-1.393zm11.057 11.67c-.24.142-.574.213-1 .213-.36 0-.65-.098-.868-.295-.207-.207-.312-.54-.312-1-.382.46-.83.793-1.343 1a4.456 4.456 0 01-1.639.295c-.383 0-.748-.044-1.098-.131a2.545 2.545 0 01-.885-.41 2.06 2.06 0 01-.606-.721c-.142-.306-.213-.672-.213-1.098 0-.48.082-.874.246-1.18.164-.306.377-.552.639-.738.273-.196.58-.344.917-.442.35-.099.705-.18 1.066-.246.382-.077.743-.131 1.081-.164.35-.044.656-.098.918-.164.262-.076.47-.18.622-.311.154-.142.23-.345.23-.607 0-.306-.06-.552-.18-.737a1.1 1.1 0 00-.443-.427 1.72 1.72 0 00-.606-.196 4.412 4.412 0 00-.656-.05c-.59 0-1.081.115-1.474.345-.394.218-.607.639-.64 1.262h-1.392c.022-.525.13-.967.327-1.328.197-.36.46-.65.787-.868a3.21 3.21 0 011.114-.492 6.032 6.032 0 011.36-.148c.383 0 .76.028 1.131.082.383.055.727.17 1.032.345.306.163.552.398.738.704.186.306.278.705.278 1.197v4.36c0 .327.017.568.05.72.043.154.174.23.393.23.12 0 .262-.027.426-.082v1.082zm-2.262-4.343c-.174.13-.403.23-.688.295-.284.054-.584.103-.901.147a14.79 14.79 0 00-.934.131c-.317.044-.6.12-.852.23a1.58 1.58 0 00-.623.475c-.153.197-.23.47-.23.82 0 .229.044.426.132.59.098.153.219.278.36.377.154.098.328.169.525.213.196.043.404.065.622.065.46 0 .852-.06 1.18-.18.328-.131.596-.29.803-.475.207-.197.36-.405.459-.623.098-.23.147-.443.147-.64v-1.425zm3.559-4.098v8.474h1.393v-4.786c0-.383.049-.732.147-1.05.109-.327.267-.611.475-.851.208-.24.464-.427.77-.558a2.898 2.898 0 011.115-.196c.535 0 .955.153 1.262.459.305.305.458.72.458 1.245v5.737h1.393v-5.573c0-.459-.049-.874-.147-1.246a2.302 2.302 0 00-.475-.983 2.294 2.294 0 00-.902-.64c-.372-.152-.835-.229-1.393-.229-1.257 0-2.174.514-2.753 1.541h-.032v-1.344h-1.311zm17.123 1.278l-.394-.491a7.294 7.294 0 01-.36-.492 4.216 4.216 0 01-.279-.524 1.485 1.485 0 01-.098-.525c0-.426.143-.737.426-.934.285-.208.59-.312.918-.312.415 0 .743.126.983.377.241.24.36.53.36.87 0 .25-.049.48-.147.687a2.28 2.28 0 01-.377.541 3.227 3.227 0 01-.508.443c-.185.131-.36.251-.524.36zm2.999 5.737l1.245 1.459h1.819l-2.278-2.639c.12-.262.225-.492.311-.688.088-.208.16-.416.213-.623.066-.208.115-.432.148-.672.044-.252.082-.552.115-.902h-1.328a6.424 6.424 0 01-.377 1.82l-2.113-2.574c.284-.164.557-.344.819-.54a4.65 4.65 0 00.705-.689c.208-.251.372-.524.491-.82.12-.305.18-.633.18-.983 0-.393-.082-.737-.245-1.032a2.093 2.093 0 00-.623-.754 2.587 2.587 0 00-.918-.46 3.74 3.74 0 00-1.049-.147c-.426 0-.802.066-1.13.197a2.327 2.327 0 00-1.344 1.311 2.498 2.498 0 00-.164.902c0 .295.033.562.099.803.077.23.175.453.294.672.132.207.274.415.427.622.153.208.31.427.475.656a11.6 11.6 0 00-1.065.64c-.328.218-.617.47-.869.753-.24.273-.436.585-.59.935-.141.35-.213.753-.213 1.212 0 .24.039.541.115.902.088.36.262.71.524 1.049.262.339.635.628 1.115.869.48.24 1.114.36 1.9.36.645 0 1.268-.137 1.869-.41a3.23 3.23 0 001.442-1.229zm-3.458-4.196l2.687 3.229c-.283.426-.639.765-1.065 1.016-.414.252-.89.377-1.425.377-.284 0-.562-.049-.836-.147a2.615 2.615 0 01-.705-.394 2.164 2.164 0 01-.508-.622 1.886 1.886 0 01-.18-.82c0-.35.055-.65.164-.901.11-.263.257-.498.442-.705.186-.208.4-.394.64-.558.251-.163.513-.322.786-.475zm17.767.607h-4.867a2.82 2.82 0 01.213-.918 2.46 2.46 0 01.491-.77c.208-.22.453-.388.738-.509.295-.13.623-.196.983-.196.35 0 .666.065.95.196.295.12.547.29.754.508.22.208.388.46.508.754.131.295.207.607.23.935zm1.343 2.36h-1.376c-.12.557-.371.972-.754 1.245-.37.274-.852.41-1.442.41-.459 0-.858-.076-1.196-.23a2.4 2.4 0 01-.836-.606 2.477 2.477 0 01-.475-.885 3.448 3.448 0 01-.131-1.065h6.342a6.466 6.466 0 00-.148-1.623 4.48 4.48 0 00-.623-1.573 3.536 3.536 0 00-1.196-1.18c-.492-.317-1.114-.476-1.868-.476-.58 0-1.114.11-1.606.328a3.863 3.863 0 00-1.262.918c-.35.393-.622.858-.819 1.393a5.1 5.1 0 00-.295 1.77c.022.645.115 1.24.279 1.787.174.546.432 1.016.77 1.41.338.393.754.699 1.245.917.502.219 1.092.328 1.77.328.96 0 1.76-.24 2.392-.721.634-.48 1.043-1.197 1.23-2.147zm3.854-1.77l-3.18 4.458h1.688l2.36-3.508 2.36 3.508h1.786l-3.277-4.573 2.916-3.901h-1.671l-2.114 2.967-2.032-2.967h-1.786l2.95 4.016zm12.086-.59h-4.867a2.79 2.79 0 01.213-.918c.12-.295.283-.552.492-.77.207-.22.453-.388.737-.509.295-.13.623-.196.983-.196.35 0 .666.065.95.196.296.12.546.29.755.508.217.208.387.46.508.754.13.295.207.607.23.935zm1.344 2.36h-1.377c-.12.557-.372.972-.754 1.245-.372.274-.852.41-1.442.41-.458 0-.858-.076-1.196-.23a2.379 2.379 0 01-.835-.606 2.477 2.477 0 01-.476-.885 3.413 3.413 0 01-.13-1.065h6.34a6.577 6.577 0 00-.147-1.623 4.503 4.503 0 00-.622-1.573 3.536 3.536 0 00-1.197-1.18c-.491-.317-1.114-.476-1.868-.476-.579 0-1.114.11-1.606.328a3.863 3.863 0 00-1.261.918c-.35.393-.623.858-.82 1.393a5.1 5.1 0 00-.295 1.77c.021.645.115 1.24.279 1.787a3.98 3.98 0 00.77 1.41c.338.393.754.699 1.246.917.502.219 1.092.328 1.77.328.96 0 1.757-.24 2.392-.721.633-.48 1.043-1.197 1.229-2.147zm7.326-3.065h1.442c-.054-.503-.185-.934-.393-1.295a2.832 2.832 0 00-.803-.918 3.084 3.084 0 00-1.115-.524 4.806 4.806 0 00-1.343-.18c-.666 0-1.25.12-1.754.36-.502.23-.922.552-1.262.967a4.095 4.095 0 00-.737 1.442 6.135 6.135 0 00-.246 1.77c0 .634.082 1.219.246 1.754.176.525.426.978.754 1.36.34.383.754.678 1.245.886.504.207 1.077.311 1.72.311 1.082 0 1.934-.284 2.557-.852.635-.568 1.028-1.377 1.18-2.426h-1.426c-.086.656-.327 1.164-.72 1.524-.382.36-.918.541-1.607.541-.436 0-.813-.087-1.13-.262a2.238 2.238 0 01-.77-.688 3.318 3.318 0 01-.443-1 5.037 5.037 0 01-.13-1.148c0-.426.044-.835.13-1.229.088-.404.23-.76.426-1.065.209-.306.481-.552.82-.738.339-.186.76-.278 1.261-.278.59 0 1.06.147 1.41.442.35.295.579.71.688 1.246zm10.064 5.753v-8.474h-1.393V88.8c0 .382-.055.738-.164 1.065a2.316 2.316 0 01-.459.836c-.208.24-.47.426-.786.558-.307.13-.672.196-1.098.196-.536 0-.957-.153-1.262-.459-.307-.306-.459-.72-.459-1.245v-5.737h-1.393v5.573c0 .459.043.88.131 1.262.099.371.262.694.492.967.23.273.529.486.901.639.371.142.836.213 1.393.213.623 0 1.163-.12 1.622-.36.46-.252.836-.64 1.13-1.164h.034v1.344h1.31zm4.025-8.474v-2.54h-1.393v2.54h-1.442v1.23h1.442v5.392c0 .393.039.71.115.95.076.24.19.426.344.558.164.13.37.224.623.278.262.044.573.066.934.066h1.065v-1.23h-.64c-.218 0-.398-.005-.54-.016a.74.74 0 01-.311-.115.448.448 0 01-.164-.229 1.86 1.86 0 01-.033-.393v-5.262h1.688v-1.229h-1.688zm9.118 3.426h-4.867a2.82 2.82 0 01.213-.918 2.46 2.46 0 01.492-.77c.207-.22.453-.388.737-.509.295-.13.623-.196.983-.196.35 0 .666.065.95.196.296.12.547.29.755.508.218.208.387.46.508.754.13.295.207.607.229.935zm1.344 2.36h-1.377c-.12.557-.37.972-.754 1.245-.37.274-.852.41-1.442.41-.458 0-.858-.076-1.196-.23a2.4 2.4 0 01-.836-.606 2.477 2.477 0 01-.475-.885 3.448 3.448 0 01-.13-1.065h6.34a6.466 6.466 0 00-.147-1.623 4.48 4.48 0 00-.622-1.573 3.536 3.536 0 00-1.197-1.18c-.491-.317-1.114-.476-1.868-.476-.58 0-1.114.11-1.606.328a3.863 3.863 0 00-1.261.918c-.35.393-.623.858-.82 1.393a5.1 5.1 0 00-.295 1.77 7.05 7.05 0 00.279 1.787c.174.546.432 1.016.77 1.41.338.393.754.699 1.245.917.503.219 1.093.328 1.77.328.961 0 1.76-.24 2.393-.721s1.043-1.197 1.229-2.147zm126.596 1.377v-9.08h2.622c.72 0 1.327.103 1.819.31.491.197.89.493 1.196.886.316.382.54.852.672 1.41.141.546.213 1.169.213 1.868 0 .721-.078 1.338-.23 1.852-.143.503-.328.923-.557 1.262-.23.339-.492.607-.786.803-.285.197-.574.35-.869.46a4.367 4.367 0 01-.836.196 8.132 8.132 0 01-.655.033h-2.59zm-1.557-10.392v11.703h4.015c.971 0 1.813-.137 2.523-.41.71-.273 1.295-.666 1.754-1.18.458-.525.797-1.164 1.016-1.918.217-.765.327-1.639.327-2.622 0-1.88-.487-3.278-1.458-4.196-.973-.918-2.36-1.377-4.162-1.377h-4.015zm17.19 6.655h-4.866a2.85 2.85 0 01.213-.918 2.46 2.46 0 01.492-.77c.208-.22.454-.388.737-.509.295-.13.623-.196.983-.196.35 0 .667.065.95.196.296.12.547.29.754.508.22.208.389.46.508.754.132.295.209.607.23.935zm1.345 2.36h-1.377c-.12.557-.371.972-.754 1.245-.37.274-.852.41-1.442.41-.459 0-.857-.076-1.196-.23a2.4 2.4 0 01-.836-.606 2.495 2.495 0 01-.475-.885 3.448 3.448 0 01-.131-1.065h6.342a6.466 6.466 0 00-.148-1.623 4.458 4.458 0 00-.623-1.573 3.526 3.526 0 00-1.196-1.18c-.491-.317-1.114-.476-1.868-.476a3.91 3.91 0 00-1.606.328c-.48.219-.901.525-1.262.918a4.322 4.322 0 00-.819 1.393 5.1 5.1 0 00-.295 1.77 7.05 7.05 0 00.279 1.787c.175.546.432 1.016.77 1.41.34.393.754.699 1.245.917.504.219 1.094.328 1.77.328.962 0 1.76-.24 2.393-.721.634-.48 1.044-1.197 1.229-2.147zm1.624-5.786v11.703h1.393v-4.36h.033a2.4 2.4 0 00.557.64c.23.163.47.294.721.392.25.099.502.17.754.214.262.043.496.065.704.065.644 0 1.207-.115 1.688-.344.492-.23.896-.541 1.213-.934.328-.405.568-.875.72-1.41.165-.535.247-1.104.247-1.704s-.082-1.17-.246-1.705a4.132 4.132 0 00-.737-1.41 3.353 3.353 0 00-1.213-.983c-.481-.24-1.049-.36-1.704-.36-.59 0-1.131.109-1.623.327a2.05 2.05 0 00-1.081 1.016h-.033v-1.147h-1.393zm6.555 4.163c0 .415-.045.82-.131 1.213-.088.393-.23.743-.426 1.049a2.219 2.219 0 01-.787.738c-.317.185-.716.278-1.196.278-.481 0-.89-.087-1.23-.262a2.534 2.534 0 01-.835-.721 3.065 3.065 0 01-.459-1.016 5.185 5.185 0 01-.016-2.377c.098-.382.25-.721.459-1.016.207-.306.475-.552.803-.738.328-.186.725-.278 1.196-.278.447 0 .836.087 1.163.262.339.175.611.41.82.705.218.295.377.633.475 1.016.109.371.164.754.164 1.147zm3.192-7.392v11.703h1.393V80.785h-1.393zm4.566 7.474a4.3 4.3 0 01.197-1.36c.141-.405.333-.743.573-1.016.24-.274.519-.481.836-.623a2.57 2.57 0 012.048 0c.328.142.612.35.852.623s.426.611.557 1.016c.142.393.213.847.213 1.36s-.071.973-.213 1.377a2.868 2.868 0 01-.557 1c-.24.262-.524.464-.852.606a2.57 2.57 0 01-2.048 0 2.518 2.518 0 01-.836-.606 3.075 3.075 0 01-.573-1 4.456 4.456 0 01-.197-1.377zm-1.475 0c0 .623.088 1.202.262 1.737.175.536.437 1.006.787 1.41.35.393.78.705 1.294.934.514.219 1.104.328 1.77.328.678 0 1.268-.11 1.77-.328.514-.23.944-.54 1.294-.934.35-.404.613-.874.787-1.41a5.579 5.579 0 00.262-1.737c0-.623-.088-1.202-.262-1.737a3.852 3.852 0 00-.787-1.41 3.659 3.659 0 00-1.294-.95c-.502-.23-1.092-.345-1.77-.345-.666 0-1.256.115-1.77.345-.513.229-.944.546-1.294.95-.35.394-.612.863-.787 1.41a5.579 5.579 0 00-.262 1.737zm13.195 5.36a8.003 8.003 0 01-.492 1.049 2.468 2.468 0 01-.524.688 1.554 1.554 0 01-.64.393c-.229.088-.495.132-.802.132-.164 0-.328-.011-.492-.033a2.326 2.326 0 01-.475-.115v-1.278c.12.054.258.098.41.13.164.044.3.066.41.066.284 0 .52-.07.704-.213.197-.13.344-.322.442-.573l.574-1.426-3.36-8.425h1.574l2.474 6.933h.033l2.376-6.933h1.475l-3.687 9.605z" fill="#303030" fill-rule="nonzero"/><rect fill="#C2B7E6" fill-rule="nonzero" x="62.036" y="26.927" width="126.412" height="4.683" rx="2"/><rect fill="#C2B7E6" fill-rule="nonzero" x="266.87" y="26.927" width="125.242" height="4.683" rx="2"/></g></svg> diff --git a/app/assets/images/learn_gitlab/graduation_hat.svg b/app/assets/images/learn_gitlab/graduation_hat.svg new file mode 100644 index 00000000000..998d8d9b935 --- /dev/null +++ b/app/assets/images/learn_gitlab/graduation_hat.svg @@ -0,0 +1 @@ +<svg width="16" height="17" xmlns="http://www.w3.org/2000/svg"><path fill="#fffff" d="M1.53 7.639l-.476.88.476-.88zm0-1.758L1.054 5l.476.88zm2.257 2.982h1v-.596l-.523-.283-.477.879zm8.424 0l-.476-.88-.524.284v.596h1zm2.257-1.224l.477.88-.477-.88zm0-1.758l-.476.879.476-.88zM8.476 2.632l-.477.88.477-.88zm-.953 0l.476.88-.476-.88zM2.007 6.76l-.953-1.758c-1.396.756-1.396 2.76 0 3.516l.953-1.758zm2.257 1.224L2.007 6.76l-.953 1.758L3.31 9.742l.953-1.758zm.523 1.995V8.863h-2v1.116h2zM8 12.5c-1.949 0-3.212-1.289-3.212-2.52h-2c0 2.656 2.51 4.52 5.212 4.52v-2zm3.212-2.52c0 1.231-1.262 2.52-3.212 2.52v2c2.704 0 5.212-1.864 5.212-4.52h-2zm0-1.117v1.116h2V8.863h-2zm2.78-2.103l-2.256 1.223.953 1.759 2.257-1.224-.953-1.758zm0 0l.954 1.758c1.396-.757 1.396-2.76 0-3.516l-.953 1.758zM8 3.51l5.993 3.249.953-1.758-5.993-3.249L8 3.511zm0 0l.953-1.758a2 2 0 00-1.906 0L8 3.511zM2.007 6.76l5.992-3.25-.953-1.758-5.992 3.249.953 1.758z"/><path fill="#fffff" d="M7.228 7.541c-.187-.112-.277-.427-.201-.704.076-.276.288-.41.475-.297L11 8.644v5.316c0 .298-.163.54-.365.54-.2 0-.364-.242-.364-.54V9.37L7.228 7.54z"/></svg>
\ No newline at end of file diff --git a/app/assets/images/learn_gitlab/rectangle.svg b/app/assets/images/learn_gitlab/rectangle.svg new file mode 100644 index 00000000000..51667e77158 --- /dev/null +++ b/app/assets/images/learn_gitlab/rectangle.svg @@ -0,0 +1 @@ +<svg width="108" height="4" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="108" height="4" rx="2" fill="#C2B7E6"/></svg> diff --git a/app/assets/javascripts/actioncable_link.js b/app/assets/javascripts/actioncable_link.js new file mode 100644 index 00000000000..895a34ba157 --- /dev/null +++ b/app/assets/javascripts/actioncable_link.js @@ -0,0 +1,40 @@ +import { ApolloLink, Observable } from 'apollo-link'; +import { print } from 'graphql'; +import cable from '~/actioncable_consumer'; +import { uuids } from '~/lib/utils/uuids'; + +export default class ActionCableLink extends ApolloLink { + // eslint-disable-next-line class-methods-use-this + request(operation) { + return new Observable((observer) => { + const subscription = cable.subscriptions.create( + { + channel: 'GraphqlChannel', + query: operation.query ? print(operation.query) : null, + variables: operation.variables, + operationName: operation.operationName, + nonce: uuids()[0], + }, + { + received(data) { + if (data.errors) { + observer.error(data.errors); + } else if (data.result) { + observer.next(data.result); + } + + if (!data.more) { + observer.complete(); + } + }, + }, + ); + + return { + unsubscribe() { + subscription.unsubscribe(); + }, + }; + }); + } +} diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue index 725d3dbf388..6f4f272154a 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete.vue @@ -14,12 +14,22 @@ export default { type: Object, required: true, }, + oncallSchedules: { + type: Array, + required: false, + default: () => [], + }, }, }; </script> <template> - <shared-delete-action modal-type="delete" :username="username" :paths="paths"> + <shared-delete-action + modal-type="delete" + :username="username" + :paths="paths" + :oncall-schedules="oncallSchedules" + > <slot></slot> </shared-delete-action> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue index 0ae15bfbebb..82b09c04ab2 100644 --- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue +++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue @@ -14,12 +14,22 @@ export default { type: Object, required: true, }, + oncallSchedules: { + type: Array, + required: false, + default: () => [], + }, }, }; </script> <template> - <shared-delete-action modal-type="delete-with-contributions" :username="username" :paths="paths"> + <shared-delete-action + modal-type="delete-with-contributions" + :username="username" + :paths="paths" + :oncall-schedules="oncallSchedules" + > <slot></slot> </shared-delete-action> </template> diff --git a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue index 9107d9ccdd9..b3b68442e80 100644 --- a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue +++ b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue @@ -18,6 +18,10 @@ export default { type: String, required: true, }, + oncallSchedules: { + type: Array, + required: true, + }, }, computed: { modalAttributes() { @@ -26,6 +30,7 @@ export default { 'data-delete-user-url': this.paths.delete, 'data-gl-modal-action': this.modalType, 'data-username': this.username, + 'data-oncall-schedules': JSON.stringify(this.oncallSchedules), }; }, }, diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue index f2b501caf09..d4c0f900c94 100644 --- a/app/assets/javascripts/admin/users/components/actions/unblock.vue +++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue @@ -23,9 +23,7 @@ export default { 'data-method': 'put', 'data-modal-attributes': JSON.stringify({ title: sprintf(s__('AdminUsers|Unblock user %{username}?'), { username: this.username }), - message: s__( - 'AdminUsers|You can always unblock their account, their data will remain intact.', - ), + message: s__('AdminUsers|You can always block their account again if needed.'), okVariant: 'confirm', okTitle: s__('AdminUsers|Unblock'), }), diff --git a/app/assets/javascripts/admin/users/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/users/components/usage_ping_disabled.vue deleted file mode 100644 index 5da38495010..00000000000 --- a/app/assets/javascripts/admin/users/components/usage_ping_disabled.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; - -export default { - components: { - GlEmptyState, - GlSprintf, - GlLink, - }, - inject: { - svgPath: { - default: '', - }, - docsLink: { - default: '', - }, - primaryButtonPath: { - default: '', - }, - }, -}; -</script> -<template> - <gl-empty-state - class="js-empty-state" - :title="__('Activate user activity analysis')" - :svg-path="svgPath" - :primary-button-text="__('Turn on usage ping')" - :primary-button-link="primaryButtonPath" - > - <template #description> - <gl-sprintf - :message=" - __( - 'Turn on %{strongStart}usage ping%{strongEnd} to activate analysis of user activity, known as %{docLinkStart}Cohorts%{docLinkEnd}.', - ) - " - > - <template #docLink="{ content }"> - <gl-link :href="docsLink" target="_blank">{{ content }}</gl-link> - </template> - <template #strong="{ content }" - ><strong>{{ content }}</strong></template - > - </gl-sprintf> - </template> - </gl-empty-state> -</template> diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue index e92c97b54a3..b782526e6be 100644 --- a/app/assets/javascripts/admin/users/components/user_actions.vue +++ b/app/assets/javascripts/admin/users/components/user_actions.vue @@ -70,14 +70,14 @@ export default { </script> <template> - <div class="gl-display-flex gl-justify-content-end"> + <div class="gl-display-flex gl-justify-content-end" :data-testid="`user-actions-${user.id}`"> <gl-button v-if="hasEditAction" data-testid="edit" :href="userPaths.edit">{{ $options.i18n.edit }}</gl-button> <gl-dropdown v-if="hasDropdownActions" - data-testid="actions" + data-testid="dropdown-toggle" right class="gl-ml-2" icon="settings" @@ -109,6 +109,7 @@ export default { :key="action" :paths="userPaths" :username="user.name" + :oncall-schedules="user.oncallSchedules" :data-testid="`delete-${action}`" > {{ $options.i18n[action] }} diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue index 8b41a063abc..2fd96e38f8e 100644 --- a/app/assets/javascripts/admin/users/components/users_table.vue +++ b/app/assets/javascripts/admin/users/components/users_table.vue @@ -1,7 +1,10 @@ <script> -import { GlTable } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlSkeletonLoader, GlTable } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils'; +import { s__, __ } from '~/locale'; import UserDate from '~/vue_shared/components/user_date.vue'; +import getUsersGroupCountsQuery from '../graphql/queries/get_users_group_counts.query.graphql'; import UserActions from './user_actions.vue'; import UserAvatar from './user_avatar.vue'; @@ -11,6 +14,7 @@ const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`; export default { components: { + GlSkeletonLoader, GlTable, UserAvatar, UserActions, @@ -26,6 +30,45 @@ export default { required: true, }, }, + data() { + return { + groupCounts: [], + }; + }, + apollo: { + groupCounts: { + query: getUsersGroupCountsQuery, + variables() { + return { + usernames: this.users.map((user) => user.username), + }; + }, + update(data) { + const nodes = data?.users?.nodes || []; + const parsedIds = convertNodeIdsFromGraphQLIds(nodes); + + return parsedIds.reduce((acc, { id, groupCount }) => { + acc[id] = groupCount || 0; + return acc; + }, {}); + }, + error(error) { + createFlash({ + message: this.$options.i18n.groupCountFetchError, + captureError: true, + error, + }); + }, + skip() { + return !this.users.length; + }, + }, + }, + i18n: { + groupCountFetchError: s__( + 'AdminUsers|Could not load user group counts. Please refresh the page to try again.', + ), + }, fields: [ { key: 'name', @@ -38,6 +81,11 @@ export default { thClass: thWidthClass(10), }, { + key: 'groupCount', + label: __('Groups'), + thClass: thWidthClass(10), + }, + { key: 'createdAt', label: __('Created on'), thClass: thWidthClass(15), @@ -50,7 +98,7 @@ export default { { key: 'settings', label: '', - thClass: thWidthClass(20), + thClass: thWidthClass(10), }, ], }; @@ -64,6 +112,7 @@ export default { :empty-text="s__('AdminUsers|No users found')" show-empty stacked="md" + data-qa-selector="user_row_content" > <template #cell(name)="{ item: user }"> <user-avatar :user="user" :admin-user-path="paths.adminUser" /> @@ -77,6 +126,17 @@ export default { <user-date :date="lastActivityOn" show-never /> </template> + <template #cell(groupCount)="{ item: { id } }"> + <div :data-testid="`user-group-count-${id}`"> + <gl-skeleton-loader v-if="$apollo.loading" :width="40" :lines="1" /> + <span v-else>{{ groupCounts[id] }}</span> + </div> + </template> + + <template #cell(projectsCount)="{ item: { id, projectsCount } }"> + <div :data-testid="`user-project-count-${id}`">{{ projectsCount }}</div> + </template> + <template #cell(settings)="{ item: user }"> <user-actions :user="user" :paths="paths" /> </template> diff --git a/app/assets/javascripts/admin/users/graphql/queries/get_users_group_counts.query.graphql b/app/assets/javascripts/admin/users/graphql/queries/get_users_group_counts.query.graphql new file mode 100644 index 00000000000..0d8e199f16e --- /dev/null +++ b/app/assets/javascripts/admin/users/graphql/queries/get_users_group_counts.query.graphql @@ -0,0 +1,8 @@ +query getUsersGroupCounts($usernames: [String!]) { + users(usernames: $usernames) { + nodes { + id + groupCount + } + } +} diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js index 0365d054fc9..54c8edc080b 100644 --- a/app/assets/javascripts/admin/users/index.js +++ b/app/assets/javascripts/admin/users/index.js @@ -1,7 +1,14 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import AdminUsersApp from './components/app.vue'; -import UsagePingDisabled from './components/usage_ping_disabled.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), +}); export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => { if (!el) { @@ -12,6 +19,7 @@ export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-a return new Vue({ el, + apolloProvider, render: (createElement) => createElement(AdminUsersApp, { props: { @@ -21,23 +29,3 @@ export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-a }), }); }; - -export const initCohortsEmptyState = (el = document.querySelector('#js-cohorts-empty-state')) => { - if (!el) { - return false; - } - - const { emptyStateSvgPath, enableUsagePingLink, docsLink } = el.dataset; - - return new Vue({ - el, - provide: { - svgPath: emptyStateSvgPath, - primaryButtonPath: enableUsagePingLink, - docsLink, - }, - render(h) { - return h(UsagePingDisabled); - }, - }); -}; diff --git a/app/assets/javascripts/admin/users/tabs.js b/app/assets/javascripts/admin/users/tabs.js deleted file mode 100644 index cbaab7df4e9..00000000000 --- a/app/assets/javascripts/admin/users/tabs.js +++ /dev/null @@ -1,32 +0,0 @@ -import Api from '~/api'; -import { historyPushState } from '~/lib/utils/common_utils'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; - -const COHORTS_PANE = 'cohorts'; -const COHORTS_PANE_TAB_CLICK_EVENT = 'i_analytics_cohorts'; - -const tabClickHandler = (e) => { - const { hash } = e.currentTarget; - - let tab = null; - - if (hash === `#${COHORTS_PANE}`) { - tab = COHORTS_PANE; - Api.trackRedisHllUserEvent(COHORTS_PANE_TAB_CLICK_EVENT); - } - - const newUrl = mergeUrlParams({ tab }, window.location.href); - historyPushState(newUrl); -}; - -const initTabs = () => { - const tabLinks = document.querySelectorAll('.js-users-tab-item a'); - - if (tabLinks.length) { - tabLinks.forEach((tabLink) => { - tabLink.addEventListener('click', (e) => tabClickHandler(e)); - }); - } -}; - -export default initTabs; diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 79a6bac3ba7..8ea977698e1 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -17,6 +17,7 @@ import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { s__, __ } from '~/locale'; import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; +import AlertsDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; import { tdClass, thClass, @@ -96,6 +97,7 @@ export default { severityLabels: SEVERITY_LEVELS, statusTabs: ALERTS_STATUS_TABS, components: { + AlertsDeprecationWarning, GlAlert, GlLoadingIcon, GlTable, @@ -273,6 +275,8 @@ export default { </gl-sprintf> </gl-alert> + <alerts-deprecation-warning /> + <paginated-table-with-search-and-tabs :show-error-msg="showErrorMsg" :i18n="$options.i18n" diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js index b23f8a8eba4..e9d19f18ab5 100644 --- a/app/assets/javascripts/alert_management/list.js +++ b/app/assets/javascripts/alert_management/list.js @@ -23,6 +23,7 @@ export default () => { assigneeUsernameQuery, alertManagementEnabled, userCanEnableAlertManagement, + hasManagedPrometheus, } = domEl.dataset; const apolloProvider = new VueApollo({ @@ -64,6 +65,7 @@ export default () => { alertManagementEnabled: parseBoolean(alertManagementEnabled), trackAlertStatusUpdateOptions: PAGE_CONFIG.OPERATIONS.TRACK_ALERT_STATUS_UPDATE_OPTIONS, userCanEnableAlertManagement: parseBoolean(userCanEnableAlertManagement), + hasManagedPrometheus: parseBoolean(hasManagedPrometheus), }, apolloProvider, render(createElement) { diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue index 5171588eb64..2733a59f62d 100644 --- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -12,7 +12,11 @@ import Vue from 'vue'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { s__, __ } from '~/locale'; import { mappingFields } from '../constants'; -import { getMappingData, transformForSave } from '../utils/mapping_transformations'; +import { + getMappingData, + transformForSave, + setFieldsLabels, +} from '../utils/mapping_transformations'; export const i18n = { columns: { @@ -72,11 +76,14 @@ export default { }, computed: { mappingData() { - return getMappingData(this.gitlabFields, this.parsedPayload, this.savedMapping); + return getMappingData(this.gitlabFields, this.formattedParsedPayload, this.savedMapping); }, hasFallbackColumn() { return this.gitlabFields.some(({ numberOfFallbacks }) => Boolean(numberOfFallbacks)); }, + formattedParsedPayload() { + return setFieldsLabels(this.parsedPayload); + }, }, methods: { setMapping(gitlabKey, mappingKey, valueKey = mappingFields.mapping) { @@ -92,14 +99,16 @@ export default { }, filterFields(searchTerm = '', fields) { const search = searchTerm.toLowerCase(); - return fields.filter((field) => field.label.toLowerCase().includes(search)); + return fields.filter((field) => + field.displayLabel.replace('...', '').toLowerCase().includes(search), + ); }, isSelected(fieldValue, mapping) { return isEqual(fieldValue, mapping); }, selectedValue(mapping) { return ( - this.parsedPayload.find((item) => isEqual(item.path, mapping))?.label || + this.formattedParsedPayload.find((item) => isEqual(item.path, mapping))?.displayLabel || this.$options.i18n.makeSelection ); }, @@ -167,11 +176,13 @@ export default { <gl-dropdown-item v-for="mappingField in filterFields(gitlabField.searchTerm, gitlabField.mappingFields)" :key="`${mappingField.path}__mapping`" + v-gl-tooltip :is-checked="isSelected(gitlabField.mapping, mappingField.path)" is-check-item + :title="mappingField.tooltip" @click="setMapping(gitlabField.name, mappingField.path)" > - {{ mappingField.label }} + {{ mappingField.displayLabel }} </gl-dropdown-item> <gl-dropdown-item v-if="noResults(gitlabField.searchTerm, gitlabField.mappingFields)"> {{ $options.i18n.noResults }} @@ -197,13 +208,15 @@ export default { gitlabField.mappingFields, )" :key="`${mappingField.path}__fallback`" + v-gl-tooltip :is-checked="isSelected(gitlabField.fallback, mappingField.path)" is-check-item + :title="mappingField.tooltip" @click=" setMapping(gitlabField.name, mappingField.path, $options.mappingFields.fallback) " > - {{ mappingField.label }} + {{ mappingField.displayLabel }} </gl-dropdown-item> <gl-dropdown-item v-if="noResults(gitlabField.fallbackSearchTerm, gitlabField.mappingFields)" diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue index ef29fc5e8b4..d9e5878b9e3 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -116,7 +116,7 @@ export default { methods: { tbodyTrClass(item) { return { - [bodyTrClass]: this.integrations.length, + [bodyTrClass]: this.integrations?.length, 'gl-bg-blue-50': (item !== null && item.id) === this.currentIntegration?.id, }; }, diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index f51c8d7e9f7..3917e4c5fdd 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -14,13 +14,12 @@ import updateCurrentHttpIntegrationMutation from '../graphql/mutations/update_cu import updateCurrentPrometheusIntegrationMutation from '../graphql/mutations/update_current_prometheus_integration.mutation.graphql'; import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql'; import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; -import getHttpIntegrationsQuery from '../graphql/queries/get_http_integrations.query.graphql'; +import getHttpIntegrationQuery from '../graphql/queries/get_http_integration.query.graphql'; import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql'; import service from '../services'; import { updateStoreAfterIntegrationDelete, updateStoreAfterIntegrationAdd, - updateStoreAfterHttpIntegrationAdd, } from '../utils/cache_updates'; import { DELETE_INTEGRATION_ERROR, @@ -68,33 +67,8 @@ export default { }; }, update(data) { - const { alertManagementIntegrations: { nodes: list = [] } = {} } = data.project || {}; - - return { - list, - }; - }, - error(err) { - createFlash({ message: err }); - }, - }, - // TODO: we'll need to update the logic to request specific http integration by its id on edit - // when BE adds support for it https://gitlab.com/gitlab-org/gitlab/-/issues/321674 - // currently the request for ALL http integrations is made and on specific integration edit we search it in the list - httpIntegrations: { - fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, - query: getHttpIntegrationsQuery, - variables() { - return { - projectPath: this.projectPath, - }; - }, - update(data) { - const { alertManagementHttpIntegrations: { nodes: list = [] } = {} } = data.project || {}; - - return { - list, - }; + const { alertManagementIntegrations: { nodes = [] } = {} } = data.project || {}; + return nodes; }, error(err) { createFlash({ message: err }); @@ -107,9 +81,9 @@ export default { data() { return { isUpdating: false, - integrations: {}, - httpIntegrations: {}, + integrations: [], currentIntegration: null, + currentHttpIntegration: null, newIntegration: null, formVisible: false, showSuccessfulCreateAlert: false, @@ -121,7 +95,7 @@ export default { return this.$apollo.queries.integrations.loading; }, canAddIntegration() { - return this.multiIntegrations || this.integrations?.list?.length < 2; + return this.multiIntegrations || this.integrations.length < 2; }, }, methods: { @@ -142,11 +116,6 @@ export default { }, update(store, { data }) { updateStoreAfterIntegrationAdd(store, getIntegrationsQuery, data, { projectPath }); - if (isHttp) { - updateStoreAfterHttpIntegrationAdd(store, getHttpIntegrationsQuery, data, { - projectPath, - }); - } }, }) .then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => { @@ -253,15 +222,38 @@ export default { }); }, editIntegration({ id, type }) { - let currentIntegration = this.integrations.list.find((integration) => integration.id === id); - if (this.isHttp(type)) { - const httpIntegrationMappingData = this.httpIntegrations.list.find( - (integration) => integration.id === id, - ); - currentIntegration = { ...currentIntegration, ...httpIntegrationMappingData }; - } + const currentIntegration = this.integrations.find((integration) => integration.id === id); - this.viewIntegration(currentIntegration, tabIndices.viewCredentials); + if (this.multiIntegrations && this.isHttp(type)) { + this.$apollo.addSmartQuery('currentHttpIntegration', { + query: getHttpIntegrationQuery, + variables() { + return { + projectPath: this.projectPath, + id, + }; + }, + update(data) { + const { + project: { + alertManagementHttpIntegrations: { nodes = [{}] }, + }, + } = data; + return nodes[0]; + }, + result() { + this.viewIntegration( + { ...currentIntegration, ...this.currentHttpIntegration }, + tabIndices.viewCredentials, + ); + }, + error() { + createFlash({ message: DEFAULT_ERROR }); + }, + }); + } else { + this.viewIntegration(currentIntegration, tabIndices.viewCredentials); + } }, viewIntegration(integration, tabIndex) { this.$apollo @@ -368,7 +360,7 @@ export default { </gl-alert> <integrations-list - :integrations="integrations.list" + :integrations="integrations" :loading="loading" @edit-integration="editIntegration" @delete-integration="deleteIntegration" diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql index 5bd63820629..e9230812db2 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql @@ -6,7 +6,6 @@ mutation updateCurrentPrometheusIntegration( $type: String $url: String $apiUrl: String - $samplePayload: String ) { updateCurrentIntegration( id: $id @@ -16,6 +15,5 @@ mutation updateCurrentPrometheusIntegration( type: $type url: $url apiUrl: $apiUrl - samplePayload: $samplePayload ) @client } diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql index 833a2d6c12f..d20a8b8334b 100644 --- a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql @@ -1,9 +1,8 @@ #import "ee_else_ce/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql" -# TODO: this query need to accept http integration id to request a sepcific integration -query getHttpIntegrations($projectPath: ID!) { +query getHttpIntegration($projectPath: ID!, $id: ID) { project(fullPath: $projectPath) { - alertManagementHttpIntegrations { + alertManagementHttpIntegrations(id: $id) { nodes { ...HttpIntegrationPayloadData } diff --git a/app/assets/javascripts/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js index 716c709a931..a50b6515afa 100644 --- a/app/assets/javascripts/alerts_settings/utils/cache_updates.js +++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js @@ -58,31 +58,6 @@ const addIntegrationToStore = ( }); }; -const addHttpIntegrationToStore = (store, query, { httpIntegrationCreate }, variables) => { - const integration = httpIntegrationCreate?.integration; - if (!integration) { - return; - } - - const sourceData = store.readQuery({ - query, - variables, - }); - - const data = produce(sourceData, (draftData) => { - draftData.project.alertManagementHttpIntegrations.nodes = [ - integration, - ...draftData.project.alertManagementHttpIntegrations.nodes, - ]; - }); - - store.writeQuery({ - query, - variables, - data, - }); -}; - const onError = (data, message) => { createFlash({ message }); throw new Error(data.errors); @@ -105,11 +80,3 @@ export const updateStoreAfterIntegrationAdd = (store, query, data, variables) => addIntegrationToStore(store, query, data, variables); } }; - -export const updateStoreAfterHttpIntegrationAdd = (store, query, data, variables) => { - if (hasErrors(data)) { - onError(data, ADD_INTEGRATION_ERROR); - } else { - addHttpIntegrationToStore(store, query, data, variables); - } -}; diff --git a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js index 5c4b9bcd505..ed126dfafd6 100644 --- a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js +++ b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js @@ -1,4 +1,6 @@ import { isEqual } from 'lodash'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; + /** * Given data for GitLab alert fields, parsed payload fields data and previously stored mapping (if any) * creates an object in a form convenient to build UI && interact with it @@ -32,6 +34,26 @@ export const getMappingData = (gitlabFields, payloadFields, savedMapping) => { }); }; +export const setFieldsLabels = (fields) => { + return fields.map((field) => { + const { label } = field; + let displayLabel; + let tooltip; + const labels = label.split('/'); + if (labels.length > 1) { + tooltip = labels.join('.'); + displayLabel = `...${capitalizeFirstCharacter(labels.pop())}`; + } else { + displayLabel = capitalizeFirstCharacter(label); + } + + return { + ...field, + displayLabel, + tooltip, + }; + }); +}; /** * Based on mapping data configured by the user creates an object in a format suitable for save on BE * @param {Object} mappingData - structure describing mapping between GitLab fields and parsed payload fields diff --git a/app/assets/javascripts/analytics/devops_report/components/devops_score.vue b/app/assets/javascripts/analytics/devops_report/components/devops_score.vue new file mode 100644 index 00000000000..1a3289ffb75 --- /dev/null +++ b/app/assets/javascripts/analytics/devops_report/components/devops_score.vue @@ -0,0 +1,110 @@ +<script> +import { GlBadge, GlTable, GlLink, GlEmptyState } from '@gitlab/ui'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; +import { sprintf, s__ } from '~/locale'; + +const defaultHeaderAttrs = { + thClass: 'gl-bg-white!', + thAttr: { 'data-testid': 'header' }, +}; + +export default { + components: { + GlBadge, + GlTable, + GlSingleStat, + GlLink, + GlEmptyState, + }, + inject: { + devopsScoreMetrics: { + default: null, + }, + devopsReportDocsPath: { + default: '', + }, + noDataImagePath: { + default: '', + }, + }, + computed: { + titleHelperText() { + return sprintf( + s__( + 'DevopsReport|DevOps score metrics are based on usage over the last 30 days. Last updated: %{timestamp}.', + ), + { timestamp: this.devopsScoreMetrics.createdAt }, + ); + }, + isEmpty() { + return this.devopsScoreMetrics.averageScore === undefined; + }, + }, + tableHeaderFields: [ + { + key: 'title', + label: '', + ...defaultHeaderAttrs, + }, + { + key: 'usage', + label: s__('DevopsReport|Your usage'), + ...defaultHeaderAttrs, + }, + { + key: 'leadInstance', + label: s__('DevopsReport|Leader usage'), + ...defaultHeaderAttrs, + }, + { + key: 'score', + label: s__('DevopsReport|Score'), + ...defaultHeaderAttrs, + }, + ], +}; +</script> +<template> + <gl-empty-state + v-if="isEmpty" + :title="__('Data is still calculating...')" + :svg-path="noDataImagePath" + > + <template #description> + <p class="gl-mb-0">{{ __('It may be several days before you see feature usage data.') }}</p> + <gl-link :href="devopsReportDocsPath">{{ + __('See example DevOps Score page in our documentation.') + }}</gl-link> + </template> + </gl-empty-state> + <div v-else data-testid="devops-score-app"> + <div class="gl-text-gray-400 gl-my-4" data-testid="devops-score-note-text"> + {{ titleHelperText }} + </div> + <gl-single-stat + unit="%" + size="sm" + :title="s__('DevopsReport|Your score')" + :should-animate="true" + :value="devopsScoreMetrics.averageScore.value" + :meta-icon="devopsScoreMetrics.averageScore.scoreLevel.icon" + :meta-text="devopsScoreMetrics.averageScore.scoreLevel.label" + :variant="devopsScoreMetrics.averageScore.scoreLevel.variant" + /> + <gl-table + :fields="$options.tableHeaderFields" + :items="devopsScoreMetrics.cards" + thead-class="gl-border-t-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" + stacked="sm" + > + <template #cell(usage)="{ item }"> + <div data-testid="usageCol"> + <span>{{ item.usage }}</span> + <gl-badge :variant="item.scoreLevel.variant" size="sm" class="gl-ml-1">{{ + item.scoreLevel.label + }}</gl-badge> + </div> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/analytics/devops_report/devops_score.js b/app/assets/javascripts/analytics/devops_report/devops_score.js new file mode 100644 index 00000000000..18f7cf0c3ab --- /dev/null +++ b/app/assets/javascripts/analytics/devops_report/devops_score.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import DevopsScore from './components/devops_score.vue'; + +export default () => { + const el = document.getElementById('js-devops-score'); + + if (!el) return false; + + const { devopsScoreMetrics, devopsReportDocsPath, noDataImagePath } = el.dataset; + + return new Vue({ + el, + provide: { + devopsScoreMetrics: JSON.parse(devopsScoreMetrics), + devopsReportDocsPath, + noDataImagePath, + }, + render(h) { + return h(DevopsScore); + }, + }); +}; diff --git a/app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js index 0cb8d9be0e4..0131407e723 100644 --- a/app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js +++ b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js @@ -6,7 +6,7 @@ export default () => { // eslint-disable-next-line no-new new UserCallout(); - const emptyStateContainer = document.getElementById('js-devops-empty-state'); + const emptyStateContainer = document.getElementById('js-devops-usage-ping-disabled'); if (!emptyStateContainer) return false; diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue index 55642aa64db..f89600fbed3 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue +++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue @@ -1,5 +1,5 @@ <script> -import { GlSprintf, GlButton, GlAlert } from '@gitlab/ui'; +import { GlSprintf, GlButton, GlAlert, GlCard } from '@gitlab/ui'; import Mousetrap from 'mousetrap'; import { __ } from '~/locale'; import Tracking from '~/tracking'; @@ -34,7 +34,7 @@ export default { recoveryCodeDownloadFilename: RECOVERY_CODE_DOWNLOAD_FILENAME, i18n, mousetrap: null, - components: { GlSprintf, GlButton, GlAlert, ClipboardButton }, + components: { GlSprintf, GlButton, GlAlert, ClipboardButton, GlCard }, mixins: [Tracking.mixin()], props: { codes: { @@ -116,8 +116,8 @@ export default { </gl-sprintf> </p> - <div - class="codes-to-print gl-my-5 gl-p-5 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base" + <gl-card + class="codes-to-print gl-my-5" data-testid="recovery-codes" data-qa-selector="codes_content" > @@ -126,7 +126,7 @@ export default { <span class="gl-font-monospace" data-qa-selector="code_content">{{ code }}</span> </li> </ul> - </div> + </gl-card> <div class="gl-my-n2 gl-mx-n2 gl-display-flex gl-flex-wrap"> <div class="gl-p-2"> <clipboard-button @@ -140,6 +140,7 @@ export default { </div> <div class="gl-p-2"> <gl-button + is-unsafe-link :href="codeDownloadUrl" :title="$options.i18n.downloadButton" icon="download" diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 3a2f2078e44..43f44370af8 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -12,7 +12,6 @@ import axios from './lib/utils/axios_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { __ } from './locale'; -window.axios = axios; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue index f16a547e441..86c7b4c7a6e 100644 --- a/app/assets/javascripts/badges/components/badge_list.vue +++ b/app/assets/javascripts/badges/components/badge_list.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlBadge } from '@gitlab/ui'; import { mapState } from 'vuex'; import { GROUP_BADGE } from '../constants'; import BadgeListRow from './badge_list_row.vue'; @@ -9,6 +9,7 @@ export default { components: { BadgeListRow, GlLoadingIcon, + GlBadge, }, computed: { ...mapState(['badges', 'isLoading', 'kind']), @@ -26,7 +27,7 @@ export default { <div class="card"> <div class="card-header"> {{ s__('Badges|Your badges') }} - <span v-show="!isLoading" class="badge badge-pill">{{ badges.length }}</span> + <gl-badge v-show="!isLoading" size="sm">{{ badges.length }}</gl-badge> </div> <gl-loading-icon v-show="isLoading" size="lg" class="card-body" /> <div v-if="hasNoBadges" class="card-body"> diff --git a/app/assets/javascripts/batch_comments/components/drafts_count.vue b/app/assets/javascripts/batch_comments/components/drafts_count.vue index 5e110b101eb..61718b766d8 100644 --- a/app/assets/javascripts/batch_comments/components/drafts_count.vue +++ b/app/assets/javascripts/batch_comments/components/drafts_count.vue @@ -12,7 +12,7 @@ export default { }; </script> <template> - <gl-badge size="sm" variant="success"> + <gl-badge size="sm" variant="info" class="gl-ml-2"> {{ draftsCount }} <span class="sr-only"> {{ n__('draft', 'drafts', draftsCount) }} </span> </gl-badge> diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index fb643d441ec..91b3b6a685c 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -1,6 +1,6 @@ <script> import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; -import { mapActions, mapGetters } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import PreviewItem from './preview_item.vue'; export default { @@ -11,13 +11,22 @@ export default { PreviewItem, }, computed: { + ...mapState('diffs', ['viewDiffsFileByFile']), ...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']), }, methods: { + ...mapActions('diffs', ['toggleActiveFileByHash']), ...mapActions('batchComments', ['scrollToDraft']), isLast(index) { return index === this.sortedDrafts.length - 1; }, + async onClickDraft(draft) { + if (this.viewDiffsFileByFile && draft.file_hash) { + await this.toggleActiveFileByHash(draft.file_hash); + } + + await this.scrollToDraft(draft); + }, }, }; </script> @@ -26,7 +35,7 @@ export default { <gl-dropdown :header-text="n__('%d pending comment', '%d pending comments', draftsCount)" dropup - toggle-class="qa-review-preview-toggle" + data-qa-selector="review_preview_dropdown" > <template #button-content> {{ __('Pending comments') }} @@ -35,7 +44,8 @@ export default { <gl-dropdown-item v-for="(draft, index) in sortedDrafts" :key="draft.id" - @click="scrollToDraft(draft)" + data-testid="preview-item" + @click="onClickDraft(draft)" > <preview-item :draft="draft" :is-last="isLast(index)" /> </gl-dropdown-item> diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue index 2a7be605003..d4fc4ad744a 100644 --- a/app/assets/javascripts/batch_comments/components/publish_button.vue +++ b/app/assets/javascripts/batch_comments/components/publish_button.vue @@ -22,7 +22,7 @@ export default { variant: { type: String, required: false, - default: 'success', + default: 'confirm', }, }, computed: { diff --git a/app/assets/javascripts/behaviors/date_picker.js b/app/assets/javascripts/behaviors/date_picker.js new file mode 100644 index 00000000000..efd89ec4330 --- /dev/null +++ b/app/assets/javascripts/behaviors/date_picker.js @@ -0,0 +1,33 @@ +import $ from 'jquery'; +import Pikaday from 'pikaday'; +import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'; + +export default function initDatePickers() { + $('.datepicker').each(function initPikaday() { + const $datePicker = $(this); + const datePickerVal = $datePicker.val(); + + const calendar = new Pikaday({ + field: $datePicker.get(0), + theme: 'gitlab-theme animate-picker', + format: 'yyyy-mm-dd', + container: $datePicker.parent().get(0), + parse: (dateString) => parsePikadayDate(dateString), + toString: (date) => pikadayToString(date), + onSelect(dateText) { + $datePicker.val(calendar.toString(dateText)); + }, + firstDay: gon.first_day_of_week, + }); + + calendar.setDate(parsePikadayDate(datePickerVal)); + + $datePicker.data('pikaday', calendar); + }); + + $('.js-clear-due-date,.js-clear-start-date').on('click', (e) => { + e.preventDefault(); + const calendar = $(e.target).siblings('.datepicker').data('pikaday'); + calendar.setDate(null); + }); +} diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js index 8238f5523f3..12f47255bdf 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -114,6 +114,12 @@ class SafeMathRenderer { throwOnError: true, maxSize: 20, maxExpand: 20, + trust: (context) => + // this config option restores the KaTeX pre-v0.11.0 + // behavior of allowing certain commands and protocols + // eslint-disable-next-line @gitlab/require-i18n-strings + ['\\url', '\\href'].includes(context.command) && + ['http', 'https', 'mailto', '_relative'].includes(context.protocol), }); } catch (e) { // Don't show a flash for now because it would override an existing flash message diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 5b5148a850b..f5b2d266c18 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import { once } from 'lodash'; +import { once, countBy } from 'lodash'; import { deprecatedCreateFlash as flash } from '~/flash'; import { darkModeEnabled } from '~/lib/utils/color_utils'; import { __, sprintf } from '~/locale'; @@ -22,6 +22,8 @@ import { __, sprintf } from '~/locale'; const MAX_CHAR_LIMIT = 2000; // Max # of mermaid blocks that can be rendered in a page. const MAX_MERMAID_BLOCK_LIMIT = 50; +// Max # of `&` allowed in Chaining of links syntax +const MAX_CHAINING_OF_LINKS_LIMIT = 30; // Keep a map of mermaid blocks we've already rendered. const elsProcessingMap = new WeakMap(); let renderedMermaidBlocks = 0; @@ -64,6 +66,18 @@ function importMermaidModule() { }); } +function shouldLazyLoadMermaidBlock(source) { + /** + * If source contains `&`, which means that it might + * contain Chaining of links a new syntax in Mermaid. + */ + if (countBy(source)['&'] > MAX_CHAINING_OF_LINKS_LIMIT) { + return true; + } + + return false; +} + function fixElementSource(el) { // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly. const source = el.textContent.replace(/<br\s*\/>/g, '<br>'); @@ -128,7 +142,8 @@ function renderMermaids($els) { if ( (source && source.length > MAX_CHAR_LIMIT) || renderedChars > MAX_CHAR_LIMIT || - renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT + renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT || + shouldLazyLoadMermaidBlock(source) ) { const html = ` <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert"> diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index 6abbd7f3243..c63dba05f10 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -375,7 +375,7 @@ export const MR_PREVIOUS_FILE_IN_DIFF = { export const MR_GO_TO_FILE = { id: 'mergeRequests.goToFile', description: __('Go to file'), - defaultKeys: ['t', 'mod+p'], + defaultKeys: ['mod+p', 't'], customizable: false, }; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcut.vue b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue new file mode 100644 index 00000000000..e5992779a99 --- /dev/null +++ b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue @@ -0,0 +1,80 @@ +<script> +import { __, s__ } from '~/locale'; + +// Map some keys to their proper representation depending on the system +// See also: https://craig.is/killing/mice#keys +const getKeyMap = () => { + const keyMap = { + up: '↑', + down: '↓', + left: '←', + right: '→', + ctrl: s__('KeyboardKey|Ctrl'), + shift: s__('KeyboardKey|Shift'), + enter: s__('KeyboardKey|Enter'), + esc: s__('KeyboardKey|Esc'), + command: '⌘', + option: window.gl?.client?.isMac ? '⌥' : s__('KeyboardKey|Alt'), + }; + + // Meta and alt are aliases + keyMap.meta = keyMap.command; + keyMap.alt = keyMap.option; + + // Mod is Command on Mac, and Ctrl on Windows/Linux + keyMap.mod = window.gl?.client?.isMac ? keyMap.command : keyMap.ctrl; + + return keyMap; +}; + +export default { + functional: true, + props: { + shortcuts: { + type: Array, + required: true, + }, + }, + + render(createElement, context) { + const keyMap = getKeyMap(); + + const { staticClass } = context.data; + + const shortcuts = context.props.shortcuts.reduce((acc, shortcut, i) => { + if ( + !window.gl?.client?.isMac && + (shortcut.includes('command') || shortcut.includes('meta')) + ) { + return acc; + } + const keys = shortcut.split(/([ +])/); + + if (i !== 0 && acc.length) { + acc.push(` ${__('or')} `); + // If there are multiple alternative shortcuts, + // we keep them on the same line if they are single-key, e.g. `]` or `j` + // but if they consist of multiple keys, we insert a line break, e.g.: + // `shift` + `]` <br> or `shift` + `j` + if (keys.length > 1) { + acc.push(createElement('br')); + } + } + + keys.forEach((key) => { + if (key === '+') { + acc.push(' + '); + } else if (key === ' ') { + acc.push(` ${__('then')} `); + } else { + acc.push(createElement('kbd', {}, [keyMap[key] ?? key])); + } + }); + + return acc; + }, []); + + return createElement('div', { staticClass }, shortcuts); + }, +}; +</script> diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue index 49216cc4aa0..cb7c6f9f6bc 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue @@ -1,525 +1,99 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlIcon, GlModal } from '@gitlab/ui'; +import { GlModal, GlSearchBoxByType } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { keybindingGroups } from './keybindings'; +import Shortcut from './shortcut.vue'; import ShortcutsToggle from './shortcuts_toggle.vue'; export default { components: { - GlIcon, GlModal, + GlSearchBoxByType, ShortcutsToggle, + Shortcut, + }, + data() { + return { + searchTerm: '', + }; }, computed: { - ctrlCharacter() { - return window.gl.client.isMac ? '⌘' : 'ctrl'; - }, - onDotCom() { - return window.gon.dot_com; + filteredKeybindings() { + if (!this.searchTerm) { + return keybindingGroups; + } + + const search = this.searchTerm.toLocaleLowerCase(); + + const mapped = keybindingGroups.map((group) => { + if (group.name.toLocaleLowerCase().includes(search)) { + return group; + } + return { + ...group, + keybindings: group.keybindings.filter((binding) => + binding.description.toLocaleLowerCase().includes(search), + ), + }; + }); + + return mapped.filter((group) => group.keybindings.length); }, }, + i18n: { + title: __(`Keyboard shortcuts`), + search: s__(`KeyboardShortcuts|Search keyboard shortcuts`), + noMatch: s__(`KeyboardShortcuts|No shortcuts matched your search`), + }, }; </script> <template> <gl-modal modal-id="keyboard-shortcut-modal" size="lg" + :title="$options.i18n.title" data-testid="modal-shortcuts" + body-class="shortcut-help-body gl-p-0!" :visible="true" :hide-footer="true" @hidden="$emit('hidden')" > - <template #modal-title> - <shortcuts-toggle /> - </template> - <div class="row"> - <div class="col-lg-4"> - <table class="shortcut-mappings text-2"> - <tbody> - <tr> - <th></th> - <th>{{ __('Global Shortcuts') }}</th> - </tr> - <tr> - <td class="shortcut"> - <kbd>?</kbd> - </td> - <td>{{ __('Toggle this dialog') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>shift p</kbd> - </td> - <td>{{ __('Go to your projects') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>shift g</kbd> - </td> - <td>{{ __('Go to your groups') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>shift a</kbd> - </td> - <td>{{ __('Go to the activity feed') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>shift l</kbd> - </td> - <td>{{ __('Go to the milestone list') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>shift s</kbd> - </td> - <td>{{ __('Go to your snippets') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>s</kbd> - / - <kbd>/</kbd> - </td> - <td>{{ __('Start search') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>shift i</kbd> - </td> - <td>{{ __('Go to your issues') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>shift m</kbd> - </td> - <td>{{ __('Go to your merge requests') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>shift t</kbd> - </td> - <td>{{ __('Go to your To-Do list') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>p</kbd> - <kbd>b</kbd> - </td> - <td>{{ __('Toggle the Performance Bar') }}</td> - </tr> - <tr v-if="onDotCom"> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>x</kbd> - </td> - <td>{{ __('Toggle GitLab Next') }}</td> - </tr> - </tbody> - <tbody> - <tr> - <th></th> - <th>{{ __('Editing') }}</th> - </tr> - <tr> - <td class="shortcut"> - <kbd>{{ ctrlCharacter }} shift p</kbd> - </td> - <td>{{ __('Toggle Markdown preview') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd> - <gl-icon name="arrow-up" /> - </kbd> - </td> - <td> - {{ __('Edit your most recent comment in a thread (from an empty textarea)') }} - </td> - </tr> - </tbody> - <tbody> - <tr> - <th></th> - <th>{{ __('Wiki') }}</th> - </tr> - <tr> - <td class="shortcut"> - <kbd>e</kbd> - </td> - <td>{{ __('Edit wiki page') }}</td> - </tr> - </tbody> - <tbody> - <tr> - <th></th> - <th>{{ __('Repository Graph') }}</th> - </tr> - <tr> - <td class="shortcut"> - <kbd> - <gl-icon name="arrow-left" /> - </kbd> - / - <kbd>h</kbd> - </td> - <td>{{ __('Scroll left') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd> - <gl-icon name="arrow-right" /> - </kbd> - / - <kbd>l</kbd> - </td> - <td>{{ __('Scroll right') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd> - <gl-icon name="arrow-up" /> - </kbd> - / - <kbd>k</kbd> - </td> - <td>{{ __('Scroll up') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd> - <gl-icon name="arrow-down" /> - </kbd> - / - <kbd>j</kbd> - </td> - <td>{{ __('Scroll down') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd> - shift - <gl-icon name="arrow-up" /> - / k - </kbd> - </td> - <td>{{ __('Scroll to top') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd> - shift - <gl-icon name="arrow-down" /> - / j - </kbd> - </td> - <td>{{ __('Scroll to bottom') }}</td> - </tr> - </tbody> - </table> - </div> - <div class="col-lg-4"> - <table class="shortcut-mappings text-2"> - <tbody> - <tr> - <th></th> - <th>{{ __('Project') }}</th> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>p</kbd> - </td> - <td>{{ __("Go to the project's overview page") }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>v</kbd> - </td> - <td>{{ __("Go to the project's activity feed") }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>r</kbd> - </td> - <td>{{ __('Go to releases') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>f</kbd> - </td> - <td>{{ __('Go to files') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>t</kbd> - </td> - <td>{{ __('Go to find file') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>c</kbd> - </td> - <td>{{ __('Go to commits') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>n</kbd> - </td> - <td>{{ __('Go to repository graph') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>d</kbd> - </td> - <td>{{ __('Go to repository charts') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>i</kbd> - </td> - <td>{{ __('Go to issues') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>i</kbd> - </td> - <td>{{ __('New issue') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>b</kbd> - </td> - <td>{{ __('Go to issue boards') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>m</kbd> - </td> - <td>{{ __('Go to merge requests') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>j</kbd> - </td> - <td>{{ __('Go to jobs') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>l</kbd> - </td> - <td>{{ __('Go to metrics') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>e</kbd> - </td> - <td>{{ __('Go to environments') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>k</kbd> - </td> - <td>{{ __('Go to kubernetes') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>s</kbd> - </td> - <td>{{ __('Go to snippets') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>g</kbd> - <kbd>w</kbd> - </td> - <td>{{ __('Go to wiki') }}</td> - </tr> - </tbody> - <tbody> - <tr> - <th></th> - <th>{{ __('Project Files') }}</th> - </tr> - <tr> - <td class="shortcut"> - <kbd> - <gl-icon name="arrow-up" /> - </kbd> - </td> - <td>{{ __('Move selection up') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd> - <gl-icon name="arrow-down" /> - </kbd> - </td> - <td>{{ __('Move selection down') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>enter</kbd> - </td> - <td>{{ __('Open Selection') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>esc</kbd> - </td> - <td>{{ __('Go back (while searching for files)') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>y</kbd> - </td> - <td>{{ __('Go to file permalink (while viewing a file)') }}</td> - </tr> - </tbody> - </table> - </div> - <div class="col-lg-4"> - <table class="shortcut-mappings text-2"> - <tbody> - <tr> - <th></th> - <th>{{ __('Epics, issues, and merge requests') }}</th> - </tr> - <tr> - <td class="shortcut"> - <kbd>r</kbd> - </td> - <td>{{ __('Comment/Reply (quoting selected text)') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>e</kbd> - </td> - <td>{{ __('Edit description') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>l</kbd> - </td> - <td>{{ __('Change label') }}</td> - </tr> - </tbody> - <tbody> - <tr> - <th></th> - <th>{{ __('Issues and merge requests') }}</th> - </tr> - <tr> - <td class="shortcut"> - <kbd>a</kbd> - </td> - <td>{{ __('Change assignee') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>m</kbd> - </td> - <td>{{ __('Change milestone') }}</td> - </tr> - </tbody> - <tbody> - <tr> - <th></th> - <th>{{ __('Merge requests') }}</th> - </tr> - <tr> - <td class="shortcut"> - <kbd>]</kbd> - / - <kbd>j</kbd> - </td> - <td>{{ __('Next file in diff') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>[</kbd> - / - <kbd>k</kbd> - </td> - <td>{{ __('Previous file in diff') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>{{ ctrlCharacter }} p</kbd> - </td> - <td>{{ __('Go to file') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>n</kbd> - </td> - <td>{{ __('Next unresolved discussion') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>p</kbd> - </td> - <td>{{ __('Previous unresolved discussion') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>b</kbd> - </td> - <td>{{ __('Copy source branch name') }}</td> - </tr> - </tbody> - <tbody> - <tr> - <th></th> - <th>{{ __('Merge request commits') }}</th> - </tr> - <tr> - <td class="shortcut"> - <kbd>c</kbd> - </td> - <td>{{ __('Next commit') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>x</kbd> - </td> - <td>{{ __('Previous commit') }}</td> - </tr> - </tbody> - <tbody> - <tr> - <th></th> - <th>{{ __('Web IDE') }}</th> - </tr> - <tr> - <td class="shortcut"> - <kbd>{{ ctrlCharacter }} p</kbd> - </td> - <td>{{ __('Go to file') }}</td> - </tr> - <tr> - <td class="shortcut"> - <kbd>{{ ctrlCharacter }} enter</kbd> - </td> - <td>{{ __('Commit (when editing commit message)') }}</td> - </tr> - </tbody> - </table> - </div> + <div + class="gl-sticky gl-top-0 gl-py-5 gl-px-5 gl-display-flex gl-align-items-center gl-bg-white" + > + <gl-search-box-by-type + v-model.trim="searchTerm" + :aria-label="$options.i18n.search" + class="gl-w-half gl-mr-3" + /> + <shortcuts-toggle class="gl-w-half gl-ml-3" /> + </div> + <div v-if="filteredKeybindings.length === 0" class="gl-px-5"> + {{ $options.i18n.noMatch }} + </div> + <div v-else class="shortcut-help-container gl-mt-8 gl-px-5 gl-pb-5"> + <section + v-for="group in filteredKeybindings" + :key="group.id" + class="shortcut-help-mapping gl-mb-4" + > + <strong class="shortcut-help-mapping-title gl-w-half gl-display-inline-block"> + {{ group.name }} + </strong> + <div + v-for="keybinding in group.keybindings" + :key="keybinding.id" + class="gl-display-flex gl-align-items-center" + > + <shortcut + class="gl-w-40p gl-flex-shrink-0 gl-text-right gl-pr-4" + :shortcuts="keybinding.defaultKeys" + /> + <div class="gl-w-half gl-flex-shrink-0 gl-flex-grow-1"> + {{ keybinding.description }} + </div> + </div> + </section> </div> </gl-modal> </template> diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue index 6cbe443062a..8f1518a1c9c 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue @@ -6,7 +6,7 @@ import { disableShortcuts, enableShortcuts, shouldDisableShortcuts } from './sho export default { i18n: { - toggleLabel: __('Keyboard shortcuts'), + toggleLabel: __('Toggle shortcuts'), }, components: { GlToggle, @@ -31,14 +31,12 @@ export default { </script> <template> - <div v-if="localStorageUsable" class="d-inline-flex align-items-center js-toggle-shortcuts"> + <div v-if="localStorageUsable" class="js-toggle-shortcuts"> <gl-toggle v-model="shortcutsEnabled" - aria-describedby="shortcutsToggle" :label="$options.i18n.toggleLabel" label-position="left" @change="onChange" /> - <div id="shortcutsToggle" class="sr-only">{{ __('Enable or disable keyboard shortcuts') }}</div> </div> </template> diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index f5f06436bcc..60729c11002 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -6,6 +6,7 @@ import BlobContentError from './blob_content_error.vue'; import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from './constants'; export default { + name: 'BlobContent', components: { GlLoadingIcon, BlobContentError, diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 77910850908..59ab84bf208 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -7,7 +7,6 @@ import toast from '~/vue_shared/plugins/global_toast'; import { deprecatedCreateFlash as Flash } from '../flash'; -import BlobCiSyntaxYamlSelector from './template_selectors/ci_syntax_yaml_selector'; import BlobCiYamlSelector from './template_selectors/ci_yaml_selector'; import DockerfileSelector from './template_selectors/dockerfile_selector'; import GitignoreSelector from './template_selectors/gitignore_selector'; @@ -34,7 +33,6 @@ export default class FileTemplateMediator { this.templateSelectors = [ GitignoreSelector, BlobCiYamlSelector, - BlobCiSyntaxYamlSelector, MetricsDashboardSelector, DockerfileSelector, LicenseSelector, diff --git a/app/assets/javascripts/blob/template_selectors/ci_syntax_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_syntax_yaml_selector.js deleted file mode 100644 index c30ff4f1290..00000000000 --- a/app/assets/javascripts/blob/template_selectors/ci_syntax_yaml_selector.js +++ /dev/null @@ -1,29 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import FileTemplateSelector from '../file_template_selector'; - -export default class BlobCiSyntaxYamlSelector extends FileTemplateSelector { - constructor({ mediator }) { - super(mediator); - this.config = { - key: 'gitlab-ci-yaml', - name: '.gitlab-ci.yml', - pattern: /(.gitlab-ci.yml)/, - type: 'gitlab_ci_syntax_ymls', - dropdown: '.js-gitlab-ci-syntax-yml-selector', - wrapper: '.js-gitlab-ci-syntax-yml-selector-wrap', - }; - } - - initDropdown() { - initDeprecatedJQueryDropdown(this.$dropdown, { - data: this.$dropdown.data('data'), - filterable: true, - selectable: true, - search: { - fields: ['name'], - }, - clicked: (options) => this.reportSelectionName(options), - text: (item) => item.name, - }); - } -} diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 4741152afce..22c6b31143f 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -1,6 +1,12 @@ import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; import { __ } from '~/locale'; +import { + REPO_BLOB_LOAD_VIEWER_START, + REPO_BLOB_LOAD_VIEWER_FINISH, + REPO_BLOB_LOAD_VIEWER, +} from '~/performance/constants'; +import { performanceMarkAndMeasure } from '~/performance/utils'; import { fixTitle } from '~/tooltips'; import { deprecatedCreateFlash as Flash } from '../../flash'; import axios from '../../lib/utils/axios_utils'; @@ -130,6 +136,9 @@ export default class BlobViewer { } switchToViewer(name) { + performanceMarkAndMeasure({ + mark: REPO_BLOB_LOAD_VIEWER_START, + }); const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`); if (this.activeViewer === newViewer) return; @@ -163,6 +172,15 @@ export default class BlobViewer { handleLocationHash(); this.toggleCopyButtonState(); + performanceMarkAndMeasure({ + mark: REPO_BLOB_LOAD_VIEWER_FINISH, + measures: [ + { + name: REPO_BLOB_LOAD_VIEWER, + start: REPO_BLOB_LOAD_VIEWER_START, + }, + ], + }); }) .catch(() => new Flash(__('Error loading viewer'))); } diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 173c82ef9b0..d26af07d54f 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; +import initCodeQualityWalkthrough from '~/code_quality_walkthrough'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; @@ -38,6 +39,13 @@ const initPopovers = () => { } }; +const initCodeQualityWalkthroughStep = () => { + const codeQualityWalkthroughEl = document.querySelector('.js-code-quality-walkthrough'); + if (codeQualityWalkthroughEl) { + initCodeQualityWalkthrough(codeQualityWalkthroughEl); + } +}; + export const initUploadForm = () => { const uploadBlobForm = $('.js-upload-blob-form'); if (uploadBlobForm.length) { @@ -74,6 +82,7 @@ export default () => { isMarkdown, }); initPopovers(); + initCodeQualityWalkthroughStep(); }) .catch((e) => createFlash(e)); diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index a8b870f9b8e..f53d41dd0f4 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,6 +1,6 @@ import { sortBy, cloneDeep } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { ListType, NOT_FILTER } from './constants'; +import { ListType, NOT_FILTER, AssigneeIdParamValues } from './constants'; export function getMilestone() { return null; @@ -186,6 +186,35 @@ export function transformNotFilters(filters) { }, {}); } +export function getSupportedParams(filters, supportedFilters) { + return supportedFilters.reduce((acc, f) => { + /** + * TODO the API endpoint for the classic boards + * accepts assignee wildcard value as 'assigneeId' param - + * while the GraphQL query accepts the value in 'assigneWildcardId' field. + * Once we deprecate the classics boards, + * we should change the filtered search bar to use 'asssigneeWildcardId' as a token name. + */ + if (f === 'assigneeId' && filters[f]) { + return AssigneeIdParamValues.includes(filters[f]) + ? { + ...acc, + assigneeWildcardId: filters[f].toUpperCase(), + } + : acc; + } + + if (filters[f]) { + return { + ...acc, + [f]: filters[f], + }; + } + + return acc; + }, {}); +} + // EE-specific feature. Find the implementation in the `ee/`-folder export function transformBoardConfig() { return ''; diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index aacea0b970c..2821b799cef 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,5 +1,5 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; +import { mapActions, mapState } from 'vuex'; import BoardCardInner from './board_card_inner.vue'; export default { @@ -31,7 +31,6 @@ export default { }, computed: { ...mapState(['selectedBoardItems', 'activeId']), - ...mapGetters(['isSwimlanesOn']), isActive() { return this.item.id === this.activeId; }, @@ -46,7 +45,7 @@ export default { ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']), toggleIssue(e) { // Don't do anything if this happened on a no trigger element - if (e.target.classList.contains('js-no-trigger')) return; + if (e.target.closest('.js-no-trigger')) return; const isMultiSelect = e.ctrlKey || e.metaKey; if (isMultiSelect) { diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index 9ff2cdd76d0..0cb2e64042e 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -190,6 +190,7 @@ export default { <template v-for="label in orderedLabels"> <gl-label :key="label.id" + class="js-no-trigger" :background-color="label.color" :title="label.title" :description="label.description" diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index a4b1e6adacf..b8a38d833ad 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -4,7 +4,6 @@ import { sortBy } from 'lodash'; import Draggable from 'vuedraggable'; import { mapState, mapGetters, mapActions } from 'vuex'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; -import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options'; import defaultSortableConfig from '~/sortable/sortable_config'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import BoardColumn from './board_column.vue'; @@ -48,7 +47,7 @@ export default { : this.lists; }, canDragColumns() { - return !this.isEpicBoard && this.glFeatures.graphqlBoardLists && this.canAdminList; + return (this.isEpicBoard || this.glFeatures.graphqlBoardLists) && this.canAdminList; }, boardColumnWrapper() { return this.canDragColumns ? Draggable : 'div'; @@ -73,14 +72,7 @@ export default { const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); }, - handleDragOnStart() { - sortableStart(); - }, - handleDragOnEnd(params) { - sortableEnd(); - if (this.isEpicBoard) return; - const { item, newIndex, oldIndex, to } = params; const listId = item.dataset.id; @@ -108,7 +100,6 @@ export default { ref="list" v-bind="draggableOptions" class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap" - @start="handleDragOnStart" @end="handleDragOnEnd" > <board-column diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 46359cc2bca..e1f8457c0e2 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -4,13 +4,13 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; -import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { ISSUABLE } from '~/boards/constants'; import { contentTop } from '~/lib/utils/common_utils'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; +import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; export default { headerHeight: `${contentTop()}px`, @@ -18,10 +18,11 @@ export default { GlDrawer, BoardSidebarTitle, SidebarAssigneesWidget, + SidebarConfidentialityWidget, BoardSidebarTimeTracker, BoardSidebarLabelsSelect, BoardSidebarDueDate, - BoardSidebarSubscription, + SidebarSubscriptionsWidget, BoardSidebarMilestoneSelect, BoardSidebarEpicSelect: () => import('ee_component/boards/components/sidebar/board_sidebar_epic_select.vue'), @@ -30,7 +31,20 @@ export default { SidebarIterationWidget: () => import('ee_component/sidebar/components/sidebar_iteration_widget.vue'), }, - mixins: [glFeatureFlagsMixin()], + inject: { + multipleAssigneesFeatureAvailable: { + default: false, + }, + epicFeatureAvailable: { + default: false, + }, + iterationFeatureAvailable: { + default: false, + }, + weightFeatureAvailable: { + default: false, + }, + }, computed: { ...mapGetters([ 'isSidebarOpen', @@ -50,7 +64,7 @@ export default { }, }, methods: { - ...mapActions(['toggleBoardItem', 'setAssignees']), + ...mapActions(['toggleBoardItem', 'setAssignees', 'setActiveItemConfidential']), handleClose() { this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); }, @@ -72,13 +86,14 @@ export default { :iid="activeBoardItem.iid" :full-path="fullPath" :initial-assignees="activeBoardItem.assignees" - class="assignee" + :allow-multiple-assignees="multipleAssigneesFeatureAvailable" @assignees-updated="setAssignees" /> - <board-sidebar-epic-select class="epic" /> + <board-sidebar-epic-select v-if="epicFeatureAvailable" class="epic" /> <div> <board-sidebar-milestone-select /> <sidebar-iteration-widget + v-if="iterationFeatureAvailable" :iid="activeBoardItem.iid" :workspace-path="projectPathForActiveIssue" :iterations-workspace-path="groupPathForActiveIssue" @@ -89,8 +104,19 @@ export default { <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> <board-sidebar-due-date /> <board-sidebar-labels-select class="labels" /> - <board-sidebar-weight-input v-if="glFeatures.issueWeights" class="weight" /> - <board-sidebar-subscription class="subscriptions" /> + <board-sidebar-weight-input v-if="weightFeatureAvailable" class="weight" /> + <sidebar-confidentiality-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + @confidentialityUpdated="setActiveItemConfidential($event)" + /> + <sidebar-subscriptions-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + data-testid="sidebar-notifications" + /> </template> </gl-drawer> </template> diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue new file mode 100644 index 00000000000..e564af0c353 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_filtered_search.vue @@ -0,0 +1,154 @@ +<script> +import { pickBy } from 'lodash'; +import { mapActions } from 'vuex'; +import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +export default { + i18n: { + search: __('Search'), + label: __('Label'), + author: __('Author'), + }, + components: { FilteredSearch }, + inject: ['initialFilterParams'], + props: { + tokens: { + type: Array, + required: true, + }, + }, + data() { + return { + filterParams: this.initialFilterParams, + }; + }, + computed: { + urlParams() { + const { authorUsername, labelName, search } = this.filterParams; + let notParams = {}; + + if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) { + notParams = pickBy( + { + 'not[label_name][]': this.filterParams.not.labelName, + 'not[author_username]': this.filterParams.not.authorUsername, + }, + undefined, + ); + } + + return { + ...notParams, + author_username: authorUsername, + 'label_name[]': labelName, + search, + }; + }, + }, + methods: { + ...mapActions(['performSearch']), + handleFilter(filters) { + this.filterParams = this.getFilterParams(filters); + + updateHistory({ + url: setUrlParams(this.urlParams, window.location.href, true, false, true), + title: document.title, + replace: true, + }); + + this.performSearch(); + }, + getFilteredSearchValue() { + const { authorUsername, labelName, search } = this.filterParams; + const filteredSearchValue = []; + + if (authorUsername) { + filteredSearchValue.push({ + type: 'author_username', + value: { data: authorUsername, operator: '=' }, + }); + } + + if (labelName?.length) { + filteredSearchValue.push( + ...labelName.map((label) => ({ + type: 'label_name', + value: { data: label, operator: '=' }, + })), + ); + } + + if (this.filterParams['not[authorUsername]']) { + filteredSearchValue.push({ + type: 'author_username', + value: { data: this.filterParams['not[authorUsername]'], operator: '!=' }, + }); + } + + if (this.filterParams['not[labelName]']) { + filteredSearchValue.push( + ...this.filterParams['not[labelName]'].map((label) => ({ + type: 'label_name', + value: { data: label, operator: '!=' }, + })), + ); + } + + if (search) { + filteredSearchValue.push(search); + } + + return filteredSearchValue; + }, + getFilterParams(filters = []) { + const notFilters = filters.filter((item) => item.value.operator === '!='); + const equalsFilters = filters.filter((item) => item.value.operator === '='); + + return { ...this.generateParams(equalsFilters), not: { ...this.generateParams(notFilters) } }; + }, + generateParams(filters = []) { + const filterParams = {}; + const labels = []; + const plainText = []; + + filters.forEach((filter) => { + switch (filter.type) { + case 'author_username': + filterParams.authorUsername = filter.value.data; + break; + case 'label_name': + labels.push(filter.value.data); + break; + case 'filtered-search-term': + if (filter.value.data) plainText.push(filter.value.data); + break; + default: + break; + } + }); + + if (labels.length) { + filterParams.labelName = labels; + } + + if (plainText.length) { + filterParams.search = plainText.join(' '); + } + return filterParams; + }, + }, +}; +</script> + +<template> + <filtered-search + class="gl-w-full" + namespace="" + :tokens="tokens" + :search-input-placeholder="$options.i18n.search" + :initial-filter-value="getFilteredSearchValue()" + @onFilter="handleFilter" + /> +</template> diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index ca66ad6934a..f94697172ac 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -161,7 +161,7 @@ export default { const collapsed = !this.list.collapsed; this.toggleListCollapsed({ listId: this.list.id, collapsed }); - if (!this.isLoggedIn || this.isEpicBoard) { + if (!this.isLoggedIn) { this.addToLocalStorage(); } else { this.updateListFunction(); diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 997655c346a..3d7f1f38a34 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -29,17 +29,17 @@ export default { }; }, computed: { - ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']), + ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL', 'isEpicBoard']), ...mapState(['activeId', 'sidebarType', 'boardLists']), isWipLimitsOn() { - return this.glFeatures.wipLimits; + return this.glFeatures.wipLimits && !this.isEpicBoard; }, activeList() { /* Warning: Though a computed property it is not reactive because we are referencing a List Model class. Reactivity only applies to plain JS objects */ - if (this.shouldUseGraphQL) { + if (this.shouldUseGraphQL || this.isEpicBoard) { return this.boardLists[this.activeId]; } return boardsStore.state.lists.find(({ id }) => id === this.activeId); @@ -71,7 +71,7 @@ export default { deleteBoard() { // eslint-disable-next-line no-alert if (window.confirm(__('Are you sure you want to remove this list?'))) { - if (this.shouldUseGraphQL) { + if (this.shouldUseGraphQL || this.isEpicBoard) { this.removeList(this.activeId); } else { this.activeList.destroy(); diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index f78be83cd82..919ef0d3783 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -1,10 +1,12 @@ <script> import { GlLabel } from '@gitlab/ui'; import { mapGetters, mapActions } from 'vuex'; +import Api from '~/api'; import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isScopedLabel } from '~/lib/utils/common_utils'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; @@ -14,7 +16,13 @@ export default { LabelsSelect, GlLabel, }, - inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], + inject: { + labelsFetchPath: { + default: null, + }, + labelsManagePath: {}, + labelsFilterBasePath: {}, + }, data() { return { loading: false, @@ -38,6 +46,32 @@ export default { scoped: isScopedLabel(label), })); }, + fetchPath() { + /* + Labels fetched in epic boards are always group-level labels + and the correct path are passed from the backend (injected through labelsFetchPath) + + For issue boards, we should always include project-level labels and use a different endpoint. + (it requires knowing the project path of a selected issue.) + + Note 1. that we will be using GraphQL to fetch labels when we create a labels select widget. + And this component will be removed _wholesale_ https://gitlab.com/gitlab-org/gitlab/-/issues/300653. + + Note 2. Moreover, 'fetchPath' needs to be used as a key for 'labels-select' component to force updates. + 'labels-select' has its own vuex store and initializes the passed props as states + and these states aren't reactively bound to the passed props. + */ + + const projectLabelsFetchPath = mergeUrlParams( + { include_ancestor_groups: true }, + Api.buildUrl(Api.projectLabelsPath).replace( + ':namespace_path/:project_path', + this.projectPathForActiveIssue, + ), + ); + + return this.labelsFetchPath || projectLabelsFetchPath; + }, }, methods: { ...mapActions(['setActiveBoardItemLabels']), @@ -77,7 +111,12 @@ export default { </script> <template> - <board-editable-item ref="sidebarItem" :title="__('Labels')" :loading="loading"> + <board-editable-item + ref="sidebarItem" + :title="__('Labels')" + :loading="loading" + data-testid="sidebar-labels" + > <template #collapsed> <gl-label v-for="label in issueLabels" @@ -95,12 +134,13 @@ export default { <template #default="{ edit }"> <labels-select ref="labelsSelect" + :key="fetchPath" :allow-label-edit="false" :allow-label-create="false" :allow-multiselect="true" :allow-scoped-labels="true" :selected-labels="selectedLabels" - :labels-fetch-path="labelsFetchPath" + :labels-fetch-path="fetchPath" :labels-manage-path="labelsManagePath" :labels-filter-base-path="labelsFilterBasePath" :labels-list-title="__('Select label')" diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 4ebd30fe67b..d88774d11c1 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,10 +1,28 @@ +import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; import { __ } from '~/locale'; import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql'; +import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql'; +import updateBoardListMutation from './graphql/board_list_update.mutation.graphql'; + import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql'; import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql'; +export const SupportedFilters = [ + 'assigneeUsername', + 'authorUsername', + 'labelName', + 'milestoneTitle', + 'releaseTag', + 'search', + 'myReactionEmoji', + 'assigneeId', +]; + +/* eslint-disable-next-line @gitlab/require-i18n-strings */ +export const AssigneeIdParamValues = ['Any', 'None']; + export const issuableTypes = { issue: 'issue', epic: 'epic', @@ -46,9 +64,10 @@ export const NOT_FILTER = 'not['; export const flashAnimationDuration = 2000; -export default { - BoardType, - ListType, +export const listsQuery = { + [issuableTypes.issue]: { + query: boardListsQuery, + }, }; export const blockingIssuablesQueries = { @@ -57,6 +76,18 @@ export const blockingIssuablesQueries = { }, }; +export const updateListQueries = { + [issuableTypes.issue]: { + mutation: updateBoardListMutation, + }, +}; + +export const deleteListQueries = { + [issuableTypes.issue]: { + mutation: destroyBoardListMutation, + }, +}; + export const titleQueries = { [issuableTypes.issue]: { mutation: issueSetTitleMutation, @@ -74,3 +105,8 @@ export const subscriptionQueries = { mutation: updateEpicSubscriptionMutation, }, }; + +export default { + BoardType, + ListType, +}; diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 66580bdd30f..c6040f1e4aa 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -28,6 +28,10 @@ export default class FilteredSearchBoards extends FilteredSearchManager { if (vuexstore.getters.shouldUseGraphQL && vuexstore.state.boardConfig) { const boardConfigPath = transformBoardConfig(vuexstore.state.boardConfig); + // TODO Refactor: https://gitlab.com/gitlab-org/gitlab/-/issues/329274 + // here we are using "window.location.search" as a temporary store + // only to unpack the params and do another validation inside + // 'performSearch' and 'setFilter' vuex actions. if (boardConfigPath !== '') { const filterPath = window.location.search ? `${window.location.search}&` : '?'; updateHistory({ diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql index 80a37c9943d..3218c06357c 100644 --- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql +++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql @@ -2,7 +2,7 @@ query getGroupProjects($fullPath: ID!, $search: String, $after: String) { group(fullPath: $fullPath) { - projects(search: $search, after: $after, first: 100) { + projects(search: $search, after: $after, first: 100, includeSubgroups: true) { nodes { id name diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql index 7ecf9261214..47ecb55c72b 100644 --- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql @@ -13,7 +13,6 @@ fragment IssueNode on Issue { emailsDisabled confidential webUrl - subscribed relativePosition milestone { id diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index e3f9d2f24c2..1888645ef78 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,3 +1,4 @@ +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { mapActions, mapGetters } from 'vuex'; @@ -35,13 +36,27 @@ import { } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; +import introspectionQueryResultData from '~/sidebar/fragmentTypes.json'; +import { fullBoardId } from './boards_util'; import boardConfigToggle from './config_toggle'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; Vue.use(VueApollo); +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + cacheConfig: { + fragmentMatcher, + }, + assumeImmutableResults: true, + }, + ), }); let issueBoardsApp; @@ -82,10 +97,14 @@ export default () => { currentUserId: gon.current_user_id || null, canUpdate: parseBoolean($boardApp.dataset.canUpdate), canAdminList: parseBoolean($boardApp.dataset.canAdminList), - labelsFetchPath: $boardApp.dataset.labelsFetchPath, labelsManagePath: $boardApp.dataset.labelsManagePath, labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath, timeTrackingLimitToHours: parseBoolean($boardApp.dataset.timeTrackingLimitToHours), + multipleAssigneesFeatureAvailable: parseBoolean( + $boardApp.dataset.multipleAssigneesFeatureAvailable, + ), + epicFeatureAvailable: parseBoolean($boardApp.dataset.epicFeatureAvailable), + iterationFeatureAvailable: parseBoolean($boardApp.dataset.iterationFeatureAvailable), weightFeatureAvailable: parseBoolean($boardApp.dataset.weightFeatureAvailable), boardWeight: $boardApp.dataset.boardWeight ? parseInt($boardApp.dataset.boardWeight, 10) @@ -121,6 +140,7 @@ export default () => { created() { this.setInitialBoardData({ boardId: $boardApp.dataset.boardId, + fullBoardId: fullBoardId($boardApp.dataset.boardId), fullPath: $boardApp.dataset.fullPath, boardType: this.parent, disabled: this.disabled, diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 8005414962c..5158e82c320 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,8 +1,4 @@ import * as Sentry from '@sentry/browser'; -import { pick } from 'lodash'; -import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; -import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; -import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import { BoardType, ListType, @@ -11,7 +7,14 @@ import { ISSUABLE, titleQueries, subscriptionQueries, -} from '~/boards/constants'; + SupportedFilters, + deleteListQueries, + listsQuery, + updateListQueries, + issuableTypes, +} from 'ee_else_ce/boards/constants'; +import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; +import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils'; @@ -19,7 +22,6 @@ import { s__ } from '~/locale'; import { formatBoardLists, formatListIssues, - fullBoardId, formatListsPageInfo, formatIssue, formatIssueInput, @@ -27,10 +29,9 @@ import { transformNotFilters, moveItemListHelper, getMoveData, + getSupportedParams, } from '../boards_util'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; -import destroyBoardListMutation from '../graphql/board_list_destroy.mutation.graphql'; -import updateBoardListMutation from '../graphql/board_list_update.mutation.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql'; @@ -39,11 +40,6 @@ import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.g import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; import * as types from './mutation_types'; -const notImplemented = () => { - /* eslint-disable-next-line @gitlab/require-i18n-strings */ - throw new Error('Not implemented!'); -}; - export const gqlClient = createGqClient( {}, { @@ -65,16 +61,11 @@ export default { }, setFilters: ({ commit }, filters) => { - const filterParams = pick(filters, [ - 'assigneeUsername', - 'authorUsername', - 'labelName', - 'milestoneTitle', - 'releaseTag', - 'search', - 'myReactionEmoji', - ]); - filterParams.not = transformNotFilters(filters); + const filterParams = { + ...getSupportedParams(filters, SupportedFilters), + not: transformNotFilters(filters), + }; + commit(types.SET_FILTERS, filterParams); }, @@ -90,24 +81,22 @@ export default { } }, - fetchLists: ({ dispatch }) => { - dispatch('fetchIssueLists'); - }, - - fetchIssueLists: ({ commit, state, dispatch }) => { - const { boardType, filterParams, fullPath, boardId } = state; + fetchLists: ({ commit, state, dispatch }) => { + const { boardType, filterParams, fullPath, fullBoardId, issuableType } = state; const variables = { fullPath, - boardId: fullBoardId(boardId), + boardId: fullBoardId, filters: filterParams, - isGroup: boardType === BoardType.group, - isProject: boardType === BoardType.project, + ...(issuableType === issuableTypes.issue && { + isGroup: boardType === BoardType.group, + isProject: boardType === BoardType.project, + }), }; return gqlClient .query({ - query: boardListsQuery, + query: listsQuery[issuableType].query, variables, }) .then(({ data }) => { @@ -141,7 +130,7 @@ export default { { state, commit, dispatch, getters }, { backlog, labelId, milestoneId, assigneeId, iterationId }, ) => { - const { boardId } = state; + const { fullBoardId } = state; const existingList = getters.getListByLabelId(labelId); @@ -154,7 +143,7 @@ export default { .mutate({ mutation: createBoardListMutation, variables: { - boardId: fullBoardId(boardId), + boardId: fullBoardId, backlog, labelId, milestoneId, @@ -242,10 +231,13 @@ export default { dispatch('updateList', { listId, position: newPosition, backupList }); }, - updateList: ({ commit }, { listId, position, collapsed, backupList }) => { + updateList: ( + { commit, state: { issuableType } }, + { listId, position, collapsed, backupList }, + ) => { gqlClient .mutate({ - mutation: updateBoardListMutation, + mutation: updateListQueries[issuableType].mutation, variables: { listId, position, @@ -266,14 +258,14 @@ export default { commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed }); }, - removeList: ({ state, commit }, listId) => { - const listsBackup = { ...state.boardLists }; + removeList: ({ state: { issuableType, boardLists }, commit }, listId) => { + const listsBackup = { ...boardLists }; commit(types.REMOVE_LIST, listId); return gqlClient .mutate({ - mutation: destroyBoardListMutation, + mutation: deleteListQueries[issuableType].mutation, variables: { listId, }, @@ -297,11 +289,11 @@ export default { fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => { commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext }); - const { fullPath, boardId, boardType, filterParams } = state; + const { fullPath, fullBoardId, boardType, filterParams } = state; const variables = { fullPath, - boardId: fullBoardId(boardId), + boardId: fullBoardId, id: listId, filters: filterParams, isGroup: boardType === BoardType.group, @@ -430,7 +422,7 @@ export default { try { const { itemId, fromListId, toListId, moveBeforeId, moveAfterId } = moveData; const { - boardId, + fullBoardId, boardItems: { [itemId]: { iid, referencePath }, }, @@ -441,7 +433,7 @@ export default { variables: { iid, projectPath: referencePath.split(/[#]/)[0], - boardId: fullBoardId(boardId), + boardId: fullBoardId, fromListId: getIdFromGraphQLId(fromListId), toListId: getIdFromGraphQLId(toListId), moveBeforeId, @@ -653,6 +645,15 @@ export default { }); }, + setActiveItemConfidential: ({ commit, getters }, confidential) => { + const { activeBoardItem } = getters; + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: activeBoardItem.id, + prop: 'confidential', + value: confidential, + }); + }, + fetchGroupProjects: ({ commit, state }, { search = '', fetchNext = false }) => { commit(types.REQUEST_GROUP_PROJECTS, fetchNext); @@ -731,28 +732,4 @@ export default { unsetError: ({ commit }) => { commit(types.SET_ERROR, undefined); }, - - fetchBacklog: () => { - notImplemented(); - }, - - bulkUpdateIssues: () => { - notImplemented(); - }, - - fetchIssue: () => { - notImplemented(); - }, - - toggleIssueSubscription: () => { - notImplemented(); - }, - - showPage: () => { - notImplemented(); - }, - - toggleEmptyState: () => { - notImplemented(); - }, }; diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index 0589851c658..b61ecc5ccb6 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -21,7 +21,7 @@ export default { groupPathForActiveIssue: (_, getters) => { const { referencePath = '' } = getters.activeBoardItem; - return referencePath.slice(0, referencePath.indexOf('/')); + return referencePath.slice(0, referencePath.lastIndexOf('/')); }, projectPathForActiveIssue: (_, getters) => { diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 22b9905ee62..ccea2917c2c 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -9,9 +9,7 @@ export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_FAILURE'; export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS'; export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE'; export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST'; -export const REQUEST_ADD_LIST = 'REQUEST_ADD_LIST'; export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; -export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR'; export const MOVE_LIST = 'MOVE_LIST'; export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED'; @@ -20,19 +18,11 @@ export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST'; export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE'; export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS'; -export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; -export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; -export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR'; export const UPDATE_BOARD_ITEM = 'UPDATE_BOARD_ITEM'; export const REMOVE_BOARD_ITEM = 'REMOVE_BOARD_ITEM'; -export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE'; export const MUTATE_ISSUE_SUCCESS = 'MUTATE_ISSUE_SUCCESS'; -export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS'; -export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR'; export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST'; export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST'; -export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; -export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID'; export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 561c21b78c1..667628b2998 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -6,11 +6,6 @@ import { formatIssue } from '../boards_util'; import { issuableTypes } from '../constants'; import * as mutationTypes from './mutation_types'; -const notImplemented = () => { - /* eslint-disable-next-line @gitlab/require-i18n-strings */ - throw new Error('Not implemented!'); -}; - const updateListItemsCount = ({ state, listId, value }) => { const list = state.boardLists[listId]; if (state.issuableType === issuableTypes.epic) { @@ -40,8 +35,9 @@ export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId export default { [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { - const { boardType, disabled, boardId, fullPath, boardConfig, issuableType } = data; + const { boardType, disabled, boardId, fullBoardId, fullPath, boardConfig, issuableType } = data; state.boardId = boardId; + state.fullBoardId = fullBoardId; state.fullPath = fullPath; state.boardType = boardType; state.disabled = disabled; @@ -93,18 +89,10 @@ export default { state.error = s__('Boards|An error occurred while generating lists. Please reload the page.'); }, - [mutationTypes.REQUEST_ADD_LIST]: () => { - notImplemented(); - }, - [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: (state, list) => { Vue.set(state.boardLists, list.id, list); }, - [mutationTypes.RECEIVE_ADD_LIST_ERROR]: () => { - notImplemented(); - }, - [mutationTypes.MOVE_LIST]: (state, { movedList, listAtNewIndex }) => { const { boardLists } = state; Vue.set(boardLists, movedList.id, movedList); @@ -171,35 +159,11 @@ export default { state.isSettingAssignees = isLoading; }, - [mutationTypes.REQUEST_ADD_ISSUE]: () => { - notImplemented(); - }, - - [mutationTypes.RECEIVE_ADD_ISSUE_SUCCESS]: () => { - notImplemented(); - }, - - [mutationTypes.RECEIVE_ADD_ISSUE_ERROR]: () => { - notImplemented(); - }, - [mutationTypes.MUTATE_ISSUE_SUCCESS]: (state, { issue }) => { const issueId = getIdFromGraphQLId(issue.id); Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId })); }, - [mutationTypes.REQUEST_UPDATE_ISSUE]: () => { - notImplemented(); - }, - - [mutationTypes.RECEIVE_UPDATE_ISSUE_SUCCESS]: () => { - notImplemented(); - }, - - [mutationTypes.RECEIVE_UPDATE_ISSUE_ERROR]: () => { - notImplemented(); - }, - [mutationTypes.ADD_BOARD_ITEM_TO_LIST]: ( state, { itemId, listId, moveBeforeId, moveAfterId, atIndex }, @@ -219,14 +183,6 @@ export default { Vue.delete(state.boardItems, itemId); }, - [mutationTypes.SET_CURRENT_PAGE]: () => { - notImplemented(); - }, - - [mutationTypes.TOGGLE_EMPTY_STATE]: () => { - notImplemented(); - }, - [mutationTypes.REQUEST_GROUP_PROJECTS]: (state, fetchNext) => { Vue.set(state, 'groupProjectsFlags', { [fetchNext ? 'isLoadingMore' : 'isLoading']: true, diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index e5923124653..b959d97daea 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -162,23 +162,26 @@ export default { </p> </template> </gl-table> - <div class="ci-variable-actions" :class="{ 'justify-content-center': !tableIsNotEmpty }"> + <div + class="ci-variable-actions gl-display-flex" + :class="{ 'justify-content-center': !tableIsNotEmpty }" + > + <gl-button + ref="add-ci-variable" + v-gl-modal-directive="$options.modalId" + class="gl-mr-3" + data-qa-selector="add_ci_variable_button" + variant="confirm" + category="primary" + >{{ __('Add variable') }}</gl-button + > <gl-button v-if="tableIsNotEmpty" ref="secret-value-reveal-button" data-qa-selector="reveal_ci_variable_value_button" - class="gl-mr-3" @click="toggleValues(!valuesHidden)" >{{ valuesButtonText }}</gl-button > - <gl-button - ref="add-ci-variable" - v-gl-modal-directive="$options.modalId" - data-qa-selector="add_ci_variable_button" - variant="success" - category="primary" - >{{ __('Add Variable') }}</gl-button - > </div> </div> </template> diff --git a/app/assets/javascripts/code_quality_walkthrough/components/step.vue b/app/assets/javascripts/code_quality_walkthrough/components/step.vue new file mode 100644 index 00000000000..1a23c96b7d6 --- /dev/null +++ b/app/assets/javascripts/code_quality_walkthrough/components/step.vue @@ -0,0 +1,150 @@ +<script> +import { GlPopover, GlSprintf, GlButton, GlAlert } from '@gitlab/ui'; +import { STEPS, STEPSTATES } from '../constants'; +import { + isWalkthroughEnabled, + getExperimentSettings, + setExperimentSettings, + track, +} from '../utils'; + +export default { + target: '#js-code-quality-walkthrough', + components: { + GlPopover, + GlSprintf, + GlButton, + GlAlert, + }, + props: { + step: { + type: String, + required: true, + }, + link: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + dismissedSettings: getExperimentSettings(), + currentStep: STEPSTATES[this.step], + }; + }, + computed: { + isPopoverVisible() { + return ( + [ + STEPS.commitCiFile, + STEPS.runningPipeline, + STEPS.successPipeline, + STEPS.failedPipeline, + ].includes(this.step) && + isWalkthroughEnabled() && + !this.isDismissed + ); + }, + isAlertVisible() { + return this.step === STEPS.troubleshootJob && isWalkthroughEnabled() && !this.isDismissed; + }, + isDismissed() { + return this.dismissedSettings[this.step]; + }, + title() { + return this.currentStep?.title || ''; + }, + body() { + return this.currentStep?.body || ''; + }, + buttonText() { + return this.currentStep?.buttonText || ''; + }, + buttonLink() { + return [STEPS.successPipeline, STEPS.failedPipeline].includes(this.step) ? this.link : ''; + }, + placement() { + return this.currentStep?.placement || 'bottom'; + }, + offset() { + return this.currentStep?.offset || 0; + }, + }, + created() { + this.trackDisplayed(); + }, + updated() { + this.trackDisplayed(); + }, + methods: { + onDismiss() { + this.$set(this.dismissedSettings, this.step, true); + setExperimentSettings(this.dismissedSettings); + const action = [STEPS.successPipeline, STEPS.failedPipeline].includes(this.step) + ? 'view_logs' + : 'dismissed'; + this.trackAction(action); + }, + trackDisplayed() { + if (this.isPopoverVisible || this.isAlertVisible) { + this.trackAction('displayed'); + } + }, + trackAction(action) { + track(`${this.step}_${action}`); + }, + }, +}; +</script> + +<template> + <div> + <gl-popover + v-if="isPopoverVisible" + :key="step" + :target="$options.target" + :placement="placement" + :offset="offset" + show + triggers="manual" + container="viewport" + > + <template #title> + <gl-sprintf :message="title"> + <template #emoji="{ content }"> + <gl-emoji class="gl-mr-2" :data-name="content" + /></template> + </gl-sprintf> + </template> + <gl-sprintf :message="body"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #lineBreak> + <div class="gl-mt-5"></div> + </template> + <template #emoji="{ content }"> + <gl-emoji :data-name="content" /> + </template> + </gl-sprintf> + <div class="gl-mt-2 gl-text-right"> + <gl-button category="tertiary" variant="link" :href="buttonLink" @click="onDismiss"> + {{ buttonText }} + </gl-button> + </div> + </gl-popover> + <gl-alert + v-if="isAlertVisible" + variant="tip" + :title="title" + :primary-button-text="buttonText" + :primary-button-link="link" + class="gl-my-5" + @primaryAction="trackAction('clicked')" + @dismiss="onDismiss" + > + {{ body }} + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/code_quality_walkthrough/constants.js b/app/assets/javascripts/code_quality_walkthrough/constants.js new file mode 100644 index 00000000000..011df06b5cc --- /dev/null +++ b/app/assets/javascripts/code_quality_walkthrough/constants.js @@ -0,0 +1,67 @@ +import { s__ } from '~/locale'; + +export const EXPERIMENT_NAME = 'code_quality_walkthrough'; + +export const STEPS = { + commitCiFile: 'commit_ci_file', + runningPipeline: 'running_pipeline', + successPipeline: 'success_pipeline', + failedPipeline: 'failed_pipeline', + troubleshootJob: 'troubleshoot_job', +}; + +export const STEPSTATES = { + [STEPS.commitCiFile]: { + title: s__("codeQualityWalkthrough|Let's start by creating a new CI file."), + body: s__( + 'codeQualityWalkthrough|To begin with code quality, we first need to create a new CI file using our code editor. We added a code quality template in the code editor to help you get started %{emojiStart}wink%{emojiEnd} .%{lineBreak}Take some time to review the template, when you are ready, use the %{strongStart}commit changes%{strongEnd} button at the bottom of the page.', + ), + buttonText: s__('codeQualityWalkthrough|Got it'), + placement: 'right', + offset: 90, + }, + [STEPS.runningPipeline]: { + title: s__( + 'codeQualityWalkthrough|Congrats! Your first pipeline is running %{emojiStart}zap%{emojiEnd}', + ), + body: s__( + "codeQualityWalkthrough|Your pipeline can take a few minutes to run. If you enabled email notifications, you'll receive an email with your pipeline status. In the meantime, why don't you get some coffee? You earned it!", + ), + buttonText: s__('codeQualityWalkthrough|Got it'), + offset: 97, + }, + [STEPS.successPipeline]: { + title: s__( + "codeQualityWalkthrough|Well done! You've just automated your code quality review. %{emojiStart}raised_hands%{emojiEnd}", + ), + body: s__( + 'codeQualityWalkthrough|A code quality job will now run every time you or your team members commit changes to your project. You can view the results of the code quality job in the job logs.', + ), + buttonText: s__('codeQualityWalkthrough|View the logs'), + offset: 98, + }, + [STEPS.failedPipeline]: { + title: s__( + "codeQualityWalkthrough|Something went wrong. %{emojiStart}thinking%{emojiEnd} Let's fix it.", + ), + body: s__( + "codeQualityWalkthrough|Your job failed. No worries - this happens. Let's view the logs, and see how we can fix it.", + ), + buttonText: s__('codeQualityWalkthrough|View the logs'), + offset: 98, + }, + [STEPS.troubleshootJob]: { + title: s__('codeQualityWalkthrough|Troubleshoot your code quality job'), + body: s__( + 'codeQualityWalkthrough|Not sure how to fix your failed job? We have compiled some tips on how to troubleshoot code quality jobs in the documentation.', + ), + buttonText: s__('codeQualityWalkthrough|Read the documentation'), + }, +}; + +export const PIPELINE_STATUSES = { + running: 'running', + successWithWarnings: 'success-with-warnings', + success: 'success', + failed: 'failed', +}; diff --git a/app/assets/javascripts/code_quality_walkthrough/index.js b/app/assets/javascripts/code_quality_walkthrough/index.js new file mode 100644 index 00000000000..b0592b8a84b --- /dev/null +++ b/app/assets/javascripts/code_quality_walkthrough/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Step from './components/step.vue'; + +export default (el) => + new Vue({ + el, + render(createElement) { + return createElement(Step, { + props: { + step: el.dataset.step, + }, + }); + }, + }); diff --git a/app/assets/javascripts/code_quality_walkthrough/utils.js b/app/assets/javascripts/code_quality_walkthrough/utils.js new file mode 100644 index 00000000000..97c80f6eff7 --- /dev/null +++ b/app/assets/javascripts/code_quality_walkthrough/utils.js @@ -0,0 +1,38 @@ +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { getExperimentData } from '~/experimentation/utils'; +import { setCookie, getCookie, getParameterByName } from '~/lib/utils/common_utils'; +import Tracking from '~/tracking'; +import { EXPERIMENT_NAME } from './constants'; + +export function getExperimentSettings() { + return JSON.parse(getCookie(EXPERIMENT_NAME) || '{}'); +} + +export function setExperimentSettings(settings) { + setCookie(EXPERIMENT_NAME, settings); +} + +export function isWalkthroughEnabled() { + return getParameterByName(EXPERIMENT_NAME); +} + +export function track(action) { + const { data } = getExperimentSettings(); + + if (data) { + Tracking.event(EXPERIMENT_NAME, action, { + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data, + }, + }); + } +} + +export function startCodeQualityWalkthrough() { + const data = getExperimentData(EXPERIMENT_NAME); + + if (data) { + setExperimentSettings({ data }); + } +} diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index 6f496ffc6ae..29ad6cc4125 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -27,6 +27,10 @@ export default () => { if (pipelineTableViewEl.dataset.disableInitialization === undefined) { const table = new Vue({ + provide: { + artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint, + artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder, + }, render(createElement) { return createElement(CommitPipelinesTable, { props: { diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 839d4de912d..7896268acf0 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -1,18 +1,24 @@ <script> -import { EditorContent } from 'tiptap'; -import createEditor from '../services/create_editor'; +import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; +import { ContentEditor } from '../services/content_editor'; +import TopToolbar from './top_toolbar.vue'; export default { components: { - EditorContent, + TiptapEditorContent, + TopToolbar, }, - data() { - return { - editor: createEditor(), - }; + props: { + contentEditor: { + type: ContentEditor, + required: true, + }, }, }; </script> <template> - <editor-content :editor="editor" /> + <div class="md md-area" :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }"> + <top-toolbar class="gl-mb-4" :content-editor="contentEditor" /> + <tiptap-editor-content :editor="contentEditor.tiptapEditor" /> + </div> </template> diff --git a/app/assets/javascripts/content_editor/components/divider.vue b/app/assets/javascripts/content_editor/components/divider.vue new file mode 100644 index 00000000000..b77bd7b7cf3 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/divider.vue @@ -0,0 +1,3 @@ +<template> + <span class="gl-mx-3 gl-border-r-solid gl-border-r-1 gl-border-gray-200"></span> +</template> diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue new file mode 100644 index 00000000000..0af12812f3b --- /dev/null +++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue @@ -0,0 +1,65 @@ +<script> +import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { Editor as TiptapEditor } from '@tiptap/vue-2'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip, + }, + props: { + iconName: { + type: String, + required: true, + }, + tiptapEditor: { + type: TiptapEditor, + required: true, + }, + contentType: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + editorCommand: { + type: String, + required: false, + default: '', + }, + }, + computed: { + isActive() { + return this.tiptapEditor.isActive(this.contentType) && this.tiptapEditor.isFocused; + }, + }, + methods: { + execute() { + const { contentType } = this; + + if (this.editorCommand) { + this.tiptapEditor.chain()[this.editorCommand]().focus().run(); + } + + this.$emit('execute', { contentType }); + }, + }, +}; +</script> +<template> + <gl-button + v-gl-tooltip + category="tertiary" + size="small" + class="gl-mx-2" + :class="{ active: isActive }" + :aria-label="label" + :title="label" + :icon="iconName" + @click="execute" + /> +</template> diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue new file mode 100644 index 00000000000..b18649d4e57 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -0,0 +1,94 @@ +<script> +import Tracking from '~/tracking'; +import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants'; +import { ContentEditor } from '../services/content_editor'; +import Divider from './divider.vue'; +import ToolbarButton from './toolbar_button.vue'; + +const trackingMixin = Tracking.mixin({ + label: CONTENT_EDITOR_TRACKING_LABEL, +}); + +export default { + components: { + ToolbarButton, + Divider, + }, + mixins: [trackingMixin], + props: { + contentEditor: { + type: ContentEditor, + required: true, + }, + }, + methods: { + trackToolbarControlExecution({ contentType: property, value }) { + this.track(TOOLBAR_CONTROL_TRACKING_ACTION, { + property, + value, + }); + }, + }, +}; +</script> +<template> + <div + class="gl-display-flex gl-justify-content-end gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" + > + <toolbar-button + data-testid="bold" + content-type="bold" + icon-name="bold" + editor-command="toggleBold" + :label="__('Bold text')" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="italic" + content-type="italic" + icon-name="italic" + editor-command="toggleItalic" + :label="__('Italic text')" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="code" + content-type="code" + icon-name="code" + editor-command="toggleCode" + :label="__('Code')" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> + <divider /> + <toolbar-button + data-testid="blockquote" + content-type="blockquote" + icon-name="quote" + editor-command="toggleBlockquote" + :label="__('Insert a quote')" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="bullet-list" + content-type="bulletList" + icon-name="list-bulleted" + editor-command="toggleBulletList" + :label="__('Add a bullet list')" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="ordered-list" + content-type="orderedList" + icon-name="list-numbered" + editor-command="toggleOrderedList" + :label="__('Add a numbered list')" + :tiptap-editor="contentEditor.tiptapEditor" + @execute="trackToolbarControlExecution" + /> + </div> +</template> diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js index eb6deff434d..45ebd87dac9 100644 --- a/app/assets/javascripts/content_editor/constants.js +++ b/app/assets/javascripts/content_editor/constants.js @@ -3,3 +3,8 @@ import { s__ } from '~/locale'; export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__( 'ContentEditor|You have to provide a renderMarkdown function or a custom serializer', ); + +export const CONTENT_EDITOR_TRACKING_LABEL = 'content_editor'; +export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control'; +export const KEYBOARD_SHORTCUT_TRACKING_ACTION = 'execute_keyboard_shortcut'; +export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule'; diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js new file mode 100644 index 00000000000..a4297b4550c --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/blockquote.js @@ -0,0 +1,5 @@ +import { Blockquote } from '@tiptap/extension-blockquote'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = Blockquote; +export const serializer = defaultMarkdownSerializer.nodes.blockquote; diff --git a/app/assets/javascripts/content_editor/extensions/bold.js b/app/assets/javascripts/content_editor/extensions/bold.js new file mode 100644 index 00000000000..e90e7b59da0 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/bold.js @@ -0,0 +1,5 @@ +import { Bold } from '@tiptap/extension-bold'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = Bold; +export const serializer = defaultMarkdownSerializer.marks.strong; diff --git a/app/assets/javascripts/content_editor/extensions/bullet_list.js b/app/assets/javascripts/content_editor/extensions/bullet_list.js new file mode 100644 index 00000000000..178b798e2d4 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/bullet_list.js @@ -0,0 +1,5 @@ +import { BulletList } from '@tiptap/extension-bullet-list'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = BulletList; +export const serializer = defaultMarkdownSerializer.nodes.bullet_list; diff --git a/app/assets/javascripts/content_editor/extensions/code.js b/app/assets/javascripts/content_editor/extensions/code.js new file mode 100644 index 00000000000..8be50dc39c5 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/code.js @@ -0,0 +1,5 @@ +import { Code } from '@tiptap/extension-code'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = Code; +export const serializer = defaultMarkdownSerializer.marks.code; diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 1d050ed208b..ce8bd57c7e3 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -1,38 +1,27 @@ -import { CodeBlockHighlight as BaseCodeBlockHighlight } from 'tiptap-extensions'; +import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; -export default class GlCodeBlockHighlight extends BaseCodeBlockHighlight { - get schema() { - const baseSchema = super.schema; +const extractLanguage = (element) => element.firstElementChild?.getAttribute('lang'); +const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({ + addAttributes() { return { - ...baseSchema, - attrs: { - params: { - default: null, + ...this.parent(), + /* `params` is the name of the attribute that + prosemirror-markdown uses to extract the language + of a codeblock. + https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.js#L62 + */ + params: { + parseHTML: (element) => { + return { + params: extractLanguage(element), + }; }, }, - parseDOM: [ - { - tag: 'pre', - preserveWhitespace: 'full', - getAttrs: (node) => { - const code = node.querySelector('code'); - - if (!code) { - return null; - } - - return { - /* `params` is the name of the attribute that - prosemirror-markdown uses to extract the language - of a codeblock. - https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.js#L62 - */ - params: code.getAttribute('lang'), - }; - }, - }, - ], }; - } -} + }, +}); + +export const tiptapExtension = ExtendedCodeBlockLowlight; +export const serializer = defaultMarkdownSerializer.nodes.code_block; diff --git a/app/assets/javascripts/content_editor/extensions/document.js b/app/assets/javascripts/content_editor/extensions/document.js new file mode 100644 index 00000000000..99aa8d6235a --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/document.js @@ -0,0 +1,3 @@ +import Document from '@tiptap/extension-document'; + +export const tiptapExtension = Document; diff --git a/app/assets/javascripts/content_editor/extensions/dropcursor.js b/app/assets/javascripts/content_editor/extensions/dropcursor.js new file mode 100644 index 00000000000..44c378ac7db --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/dropcursor.js @@ -0,0 +1,3 @@ +import Dropcursor from '@tiptap/extension-dropcursor'; + +export const tiptapExtension = Dropcursor; diff --git a/app/assets/javascripts/content_editor/extensions/gapcursor.js b/app/assets/javascripts/content_editor/extensions/gapcursor.js new file mode 100644 index 00000000000..2db862e4580 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/gapcursor.js @@ -0,0 +1,3 @@ +import Gapcursor from '@tiptap/extension-gapcursor'; + +export const tiptapExtension = Gapcursor; diff --git a/app/assets/javascripts/content_editor/extensions/hard_break.js b/app/assets/javascripts/content_editor/extensions/hard_break.js new file mode 100644 index 00000000000..dc1ba431151 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/hard_break.js @@ -0,0 +1,5 @@ +import { HardBreak } from '@tiptap/extension-hard-break'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = HardBreak; +export const serializer = defaultMarkdownSerializer.nodes.hard_break; diff --git a/app/assets/javascripts/content_editor/extensions/heading.js b/app/assets/javascripts/content_editor/extensions/heading.js new file mode 100644 index 00000000000..f69869d1e09 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/heading.js @@ -0,0 +1,5 @@ +import { Heading } from '@tiptap/extension-heading'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = Heading; +export const serializer = defaultMarkdownSerializer.nodes.heading; diff --git a/app/assets/javascripts/content_editor/extensions/history.js b/app/assets/javascripts/content_editor/extensions/history.js new file mode 100644 index 00000000000..554d797d30a --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/history.js @@ -0,0 +1,3 @@ +import History from '@tiptap/extension-history'; + +export const tiptapExtension = History; diff --git a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js new file mode 100644 index 00000000000..dcc59476518 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js @@ -0,0 +1,5 @@ +import { HorizontalRule } from '@tiptap/extension-horizontal-rule'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = HorizontalRule; +export const serializer = defaultMarkdownSerializer.nodes.horizontal_rule; diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js new file mode 100644 index 00000000000..4f0109fd751 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -0,0 +1,9 @@ +import { Image } from '@tiptap/extension-image'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +const ExtendedImage = Image.extend({ + defaultOptions: { inline: true }, +}); + +export const tiptapExtension = ExtendedImage; +export const serializer = defaultMarkdownSerializer.nodes.image; diff --git a/app/assets/javascripts/content_editor/extensions/italic.js b/app/assets/javascripts/content_editor/extensions/italic.js new file mode 100644 index 00000000000..b8a7c4aba3e --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/italic.js @@ -0,0 +1,4 @@ +import { Italic } from '@tiptap/extension-italic'; + +export const tiptapExtension = Italic; +export const serializer = { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }; diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js new file mode 100644 index 00000000000..9a2fa7a5c98 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -0,0 +1,5 @@ +import { Link } from '@tiptap/extension-link'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = Link; +export const serializer = defaultMarkdownSerializer.marks.link; diff --git a/app/assets/javascripts/content_editor/extensions/list_item.js b/app/assets/javascripts/content_editor/extensions/list_item.js new file mode 100644 index 00000000000..86da98f6df7 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/list_item.js @@ -0,0 +1,5 @@ +import { ListItem } from '@tiptap/extension-list-item'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = ListItem; +export const serializer = defaultMarkdownSerializer.nodes.list_item; diff --git a/app/assets/javascripts/content_editor/extensions/ordered_list.js b/app/assets/javascripts/content_editor/extensions/ordered_list.js new file mode 100644 index 00000000000..d980ab8bf10 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/ordered_list.js @@ -0,0 +1,5 @@ +import { OrderedList } from '@tiptap/extension-ordered-list'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = OrderedList; +export const serializer = defaultMarkdownSerializer.nodes.ordered_list; diff --git a/app/assets/javascripts/content_editor/extensions/paragraph.js b/app/assets/javascripts/content_editor/extensions/paragraph.js new file mode 100644 index 00000000000..6c9f204b8ac --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/paragraph.js @@ -0,0 +1,5 @@ +import { Paragraph } from '@tiptap/extension-paragraph'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = Paragraph; +export const serializer = defaultMarkdownSerializer.nodes.paragraph; diff --git a/app/assets/javascripts/content_editor/extensions/text.js b/app/assets/javascripts/content_editor/extensions/text.js new file mode 100644 index 00000000000..0d76aa1f1a7 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/text.js @@ -0,0 +1,5 @@ +import { Text } from '@tiptap/extension-text'; +import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; + +export const tiptapExtension = Text; +export const serializer = defaultMarkdownSerializer.nodes.text; diff --git a/app/assets/javascripts/content_editor/index.js b/app/assets/javascripts/content_editor/index.js index e6ef3965da1..2a7dc9b713d 100644 --- a/app/assets/javascripts/content_editor/index.js +++ b/app/assets/javascripts/content_editor/index.js @@ -1,2 +1,2 @@ -export { default as createEditor } from './services/create_editor'; +export * from './services/create_content_editor'; export { default as ContentEditor } from './components/content_editor.vue'; diff --git a/app/assets/javascripts/content_editor/services/build_serializer_config.js b/app/assets/javascripts/content_editor/services/build_serializer_config.js new file mode 100644 index 00000000000..75e2b0f9eba --- /dev/null +++ b/app/assets/javascripts/content_editor/services/build_serializer_config.js @@ -0,0 +1,22 @@ +const buildSerializerConfig = (extensions = []) => + extensions + .filter(({ serializer }) => serializer) + .reduce( + (serializers, { serializer, tiptapExtension: { name, type } }) => { + const collection = `${type}s`; + + return { + ...serializers, + [collection]: { + ...serializers[collection], + [name]: serializer, + }, + }; + }, + { + nodes: {}, + marks: {}, + }, + ); + +export default buildSerializerConfig; diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js new file mode 100644 index 00000000000..e2188f5aa69 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -0,0 +1,25 @@ +/* eslint-disable no-underscore-dangle */ +export class ContentEditor { + constructor({ tiptapEditor, serializer }) { + this._tiptapEditor = tiptapEditor; + this._serializer = serializer; + } + + get tiptapEditor() { + return this._tiptapEditor; + } + + async setSerializedContent(serializedContent) { + const { _tiptapEditor: editor, _serializer: serializer } = this; + + editor.commands.setContent( + await serializer.deserialize({ schema: editor.schema, content: serializedContent }), + ); + } + + getSerializedContent() { + const { _tiptapEditor: editor, _serializer: serializer } = this; + + return serializer.serialize({ schema: editor.schema, content: editor.getJSON() }); + } +} diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js new file mode 100644 index 00000000000..df45287e6cb --- /dev/null +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -0,0 +1,76 @@ +import { Editor } from '@tiptap/vue-2'; +import { isFunction } from 'lodash'; +import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; +import * as Blockquote from '../extensions/blockquote'; +import * as Bold from '../extensions/bold'; +import * as BulletList from '../extensions/bullet_list'; +import * as Code from '../extensions/code'; +import * as CodeBlockHighlight from '../extensions/code_block_highlight'; +import * as Document from '../extensions/document'; +import * as Dropcursor from '../extensions/dropcursor'; +import * as Gapcursor from '../extensions/gapcursor'; +import * as HardBreak from '../extensions/hard_break'; +import * as Heading from '../extensions/heading'; +import * as History from '../extensions/history'; +import * as HorizontalRule from '../extensions/horizontal_rule'; +import * as Image from '../extensions/image'; +import * as Italic from '../extensions/italic'; +import * as Link from '../extensions/link'; +import * as ListItem from '../extensions/list_item'; +import * as OrderedList from '../extensions/ordered_list'; +import * as Paragraph from '../extensions/paragraph'; +import * as Text from '../extensions/text'; +import buildSerializerConfig from './build_serializer_config'; +import { ContentEditor } from './content_editor'; +import createMarkdownSerializer from './markdown_serializer'; +import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; + +const builtInContentEditorExtensions = [ + Blockquote, + Bold, + BulletList, + Code, + CodeBlockHighlight, + Document, + Dropcursor, + Gapcursor, + HardBreak, + Heading, + History, + HorizontalRule, + Image, + Italic, + Link, + ListItem, + OrderedList, + Paragraph, + Text, +]; + +const collectTiptapExtensions = (extensions = []) => + extensions.map(({ tiptapExtension }) => tiptapExtension); + +const createTiptapEditor = ({ extensions = [], ...options } = {}) => + new Editor({ + extensions: [...extensions], + editorProps: { + attributes: { + class: 'gl-outline-0!', + }, + }, + ...options, + }); + +export const createContentEditor = ({ renderMarkdown, extensions = [], tiptapOptions } = {}) => { + if (!isFunction(renderMarkdown)) { + throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); + } + + const allExtensions = [...builtInContentEditorExtensions, ...extensions]; + const tiptapExtensions = collectTiptapExtensions(allExtensions).map(trackInputRulesAndShortcuts); + const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions }); + const serializerConfig = buildSerializerConfig(allExtensions); + const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig }); + + return new ContentEditor({ tiptapEditor, serializer }); +}; diff --git a/app/assets/javascripts/content_editor/services/create_editor.js b/app/assets/javascripts/content_editor/services/create_editor.js deleted file mode 100644 index 128d332b0a2..00000000000 --- a/app/assets/javascripts/content_editor/services/create_editor.js +++ /dev/null @@ -1,60 +0,0 @@ -import { isFunction, isString } from 'lodash'; -import { Editor } from 'tiptap'; -import { - Bold, - Italic, - Code, - Link, - Image, - Heading, - Blockquote, - HorizontalRule, - BulletList, - OrderedList, - ListItem, -} from 'tiptap-extensions'; -import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; -import CodeBlockHighlight from '../extensions/code_block_highlight'; -import createMarkdownSerializer from './markdown_serializer'; - -const createEditor = async ({ content, renderMarkdown, serializer: customSerializer } = {}) => { - if (!customSerializer && !isFunction(renderMarkdown)) { - throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); - } - - const editor = new Editor({ - extensions: [ - new Bold(), - new Italic(), - new Code(), - new Link(), - new Image(), - new Heading({ levels: [1, 2, 3, 4, 5, 6] }), - new Blockquote(), - new HorizontalRule(), - new BulletList(), - new ListItem(), - new OrderedList(), - new CodeBlockHighlight(), - ], - }); - const serializer = customSerializer || createMarkdownSerializer({ render: renderMarkdown }); - - editor.setSerializedContent = async (serializedContent) => { - editor.setContent( - await serializer.deserialize({ schema: editor.schema, content: serializedContent }), - ); - }; - - editor.getSerializedContent = () => { - return serializer.serialize({ schema: editor.schema, content: editor.getJSON() }); - }; - - if (isString(content)) { - await editor.setSerializedContent(content); - } - - return editor; -}; - -export default createEditor; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index e3b5775e320..f121cc9affd 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -1,7 +1,4 @@ -import { - MarkdownSerializer as ProseMirrorMarkdownSerializer, - defaultMarkdownSerializer, -} from 'prosemirror-markdown'; +import { MarkdownSerializer as ProseMirrorMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown'; import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; const wrapHtmlPayload = (payload) => `<div>${payload}</div>`; @@ -18,56 +15,46 @@ const wrapHtmlPayload = (payload) => `<div>${payload}</div>`; * that parses the Markdown and converts it into HTML. * @returns a markdown serializer */ -const create = ({ render = () => null }) => { - return { - /** - * Converts a Markdown string into a ProseMirror JSONDocument based - * on a ProseMirror schema. - * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines - * the types of content supported in the document - * @param {String} params.content An arbitrary markdown string - * @returns A ProseMirror JSONDocument - */ - deserialize: async ({ schema, content }) => { - const html = await render(content); - - if (!html) { - return null; - } - - const parser = new DOMParser(); - const { - body: { firstElementChild }, - } = parser.parseFromString(wrapHtmlPayload(html), 'text/html'); - const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild); - - return state.toJSON(); - }, - - /** - * Converts a ProseMirror JSONDocument based - * on a ProseMirror schema into Markdown - * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines - * the types of content supported in the document - * @param {String} params.content A ProseMirror JSONDocument - * @returns A Markdown string - */ - serialize: ({ schema, content }) => { - const document = schema.nodeFromJSON(content); - const serializer = new ProseMirrorMarkdownSerializer(defaultMarkdownSerializer.nodes, { - ...defaultMarkdownSerializer.marks, - bold: { - // creates a bold alias for the strong mark converter - ...defaultMarkdownSerializer.marks.strong, - }, - italic: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }, - }); - - return serializer.serialize(document, { - tightLists: true, - }); - }, - }; -}; - -export default create; +export default ({ render = () => null, serializerConfig }) => ({ + /** + * Converts a Markdown string into a ProseMirror JSONDocument based + * on a ProseMirror schema. + * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines + * the types of content supported in the document + * @param {String} params.content An arbitrary markdown string + * @returns A ProseMirror JSONDocument + */ + deserialize: async ({ schema, content }) => { + const html = await render(content); + + if (!html) { + return null; + } + + const parser = new DOMParser(); + const { + body: { firstElementChild }, + } = parser.parseFromString(wrapHtmlPayload(html), 'text/html'); + const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild); + + return state.toJSON(); + }, + + /** + * Converts a ProseMirror JSONDocument based + * on a ProseMirror schema into Markdown + * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines + * the types of content supported in the document + * @param {String} params.content A ProseMirror JSONDocument + * @returns A Markdown string + */ + serialize: ({ schema, content }) => { + const proseMirrorDocument = schema.nodeFromJSON(content); + const { nodes, marks } = serializerConfig; + const serializer = new ProseMirrorMarkdownSerializer(nodes, marks); + + return serializer.serialize(proseMirrorDocument, { + tightLists: true, + }); + }, +}); diff --git a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js new file mode 100644 index 00000000000..860e5372bc2 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js @@ -0,0 +1,61 @@ +import { mapValues, omit } from 'lodash'; +import { InputRule } from 'prosemirror-inputrules'; +import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys'; +import Tracking from '~/tracking'; +import { + CONTENT_EDITOR_TRACKING_LABEL, + KEYBOARD_SHORTCUT_TRACKING_ACTION, + INPUT_RULE_TRACKING_ACTION, +} from '../constants'; + +const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => { + Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property: `${contentType}.${shortcut}`, + }); + return commandFn(); +}; + +const trackInputRule = (contentType, inputRule) => { + return new InputRule(inputRule.match, (...args) => { + const result = inputRule.handler(...args); + + if (result) { + Tracking.event(undefined, INPUT_RULE_TRACKING_ACTION, { + label: CONTENT_EDITOR_TRACKING_LABEL, + property: contentType, + }); + } + + return result; + }); +}; + +const trackInputRulesAndShortcuts = (tiptapExtension) => { + return tiptapExtension.extend({ + addKeyboardShortcuts() { + const shortcuts = this.parent?.() || {}; + const { name } = this; + + /** + * We don’t want to track keyboard shortcuts + * that are not deliberately executed to create + * new types of content + */ + const withoutEnterShortcut = omit(shortcuts, [ENTER_KEY, BACKSPACE_KEY]); + const decorated = mapValues(withoutEnterShortcut, (commandFn, shortcut) => + trackKeyboardShortcut(name, commandFn, shortcut), + ); + + return decorated; + }, + addInputRules() { + const inputRules = this.parent?.() || []; + const { name } = this; + + return inputRules.map((inputRule) => trackInputRule(name, inputRule)); + }, + }); +}; + +export default trackInputRulesAndShortcuts; diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 000faacb7d7..1c0dab11392 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -42,7 +42,7 @@ export default class CreateMergeRequestDropdown { this.refInput = this.wrapperEl.querySelector('.js-ref'); this.refMessage = this.wrapperEl.querySelector('.js-ref-message'); this.unavailableButton = this.wrapperEl.querySelector('.unavailable'); - this.unavailableButtonSpinner = this.unavailableButton.querySelector('.spinner'); + this.unavailableButtonSpinner = this.unavailableButton.querySelector('.gl-spinner'); this.unavailableButtonText = this.unavailableButton.querySelector('.text'); this.branchCreated = false; diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue index df77d641e21..11a263015e4 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import Cookies from 'js-cookie'; -import { deprecatedCreateFlash as Flash } from '~/flash'; +import { mapActions, mapState } from 'vuex'; import { __ } from '~/locale'; import banner from './banner.vue'; import stageCodeComponent from './stage_code_component.vue'; @@ -39,94 +39,59 @@ export default { type: String, required: true, }, - store: { - type: Object, - required: true, - }, - service: { - type: Object, - required: true, - }, }, data() { return { - state: this.store.state, - isLoading: false, - isLoadingStage: false, - isEmptyStage: false, - hasError: true, - startDate: 30, isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE), }; }, computed: { - currentStage() { - return this.store.currentActiveStage(); + ...mapState([ + 'isLoading', + 'isLoadingStage', + 'isEmptyStage', + 'selectedStage', + 'selectedStageEvents', + 'stages', + 'summary', + 'startDate', + ]), + displayStageEvents() { + const { selectedStageEvents, isLoadingStage, isEmptyStage } = this; + return selectedStageEvents.length && !isLoadingStage && !isEmptyStage; + }, + displayNotEnoughData() { + const { selectedStage, isEmptyStage, isLoadingStage } = this; + return selectedStage && isEmptyStage && !isLoadingStage; + }, + displayNoAccess() { + const { selectedStage } = this; + return selectedStage && !selectedStage.isUserAllowed; }, - }, - created() { - this.fetchCycleAnalyticsData(); }, methods: { - handleError() { - this.store.setErrorState(true); - return new Flash(__('There was an error while fetching value stream analytics data.')); - }, + ...mapActions([ + 'fetchCycleAnalyticsData', + 'fetchStageData', + 'setSelectedStage', + 'setDateRange', + ]), handleDateSelect(startDate) { - this.startDate = startDate; - this.fetchCycleAnalyticsData({ startDate: this.startDate }); + this.setDateRange({ startDate }); + this.fetchCycleAnalyticsData(); }, - fetchCycleAnalyticsData(options) { - const fetchOptions = options || { startDate: this.startDate }; - - this.isLoading = true; - - this.service - .fetchCycleAnalyticsData(fetchOptions) - .then((response) => { - this.store.setCycleAnalyticsData(response); - this.selectDefaultStage(); - }) - .catch(() => { - this.handleError(); - }) - .finally(() => { - this.isLoading = false; - }); - }, - selectDefaultStage() { - const stage = this.state.stages[0]; - this.selectStage(stage); + isActiveStage(stage) { + return stage.slug === this.selectedStage.slug; }, selectStage(stage) { - if (this.isLoadingStage) return; - if (this.currentStage === stage) return; + if (this.selectedStage === stage) return; + this.setSelectedStage(stage); if (!stage.isUserAllowed) { - this.store.setActiveStage(stage); return; } - this.isLoadingStage = true; - this.store.setStageEvents([], stage); - this.store.setActiveStage(stage); - - this.service - .fetchStageData({ - stage, - startDate: this.startDate, - projectIds: this.selectedProjectIds, - }) - .then((response) => { - this.isEmptyStage = !response.events.length; - this.store.setStageEvents(response.events, stage); - }) - .catch(() => { - this.isEmptyStage = true; - }) - .finally(() => { - this.isLoadingStage = false; - }); + this.fetchStageData(); }, dismissOverviewDialog() { this.isOverviewDialogDismissed = true; @@ -146,12 +111,13 @@ export default { <div class="card"> <div class="card-header">{{ __('Recent Project Activity') }}</div> <div class="d-flex justify-content-between"> - <div v-for="item in state.summary" :key="item.title" class="flex-grow text-center"> + <div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center"> <h3 class="header">{{ item.value }}</h3> <p class="text">{{ item.title }}</p> </div> <div class="flex-grow align-self-center text-center"> <div class="js-ca-dropdown dropdown inline"> + <!-- eslint-disable-next-line @gitlab/vue-no-data-toggle --> <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> <span class="dropdown-label"> <gl-sprintf :message="$options.i18n.dropdownText"> @@ -207,11 +173,9 @@ export default { </span> </li> <li class="event-header pl-3"> - <span - v-if="currentStage && currentStage.legend" - class="stage-name font-weight-bold" - >{{ currentStage ? __(currentStage.legend) : __('Related Issues') }}</span - > + <span v-if="selectedStage" class="stage-name font-weight-bold">{{ + selectedStage.legend ? __(selectedStage.legend) : __('Related Issues') + }}</span> <span class="has-tooltip" data-placement="top" @@ -242,19 +206,19 @@ export default { <nav class="stage-nav"> <ul> <stage-nav-item - v-for="stage in state.stages" + v-for="stage in stages" :key="stage.title" :title="stage.title" :is-user-allowed="stage.isUserAllowed" :value="stage.value" - :is-active="stage.active" + :is-active="isActiveStage(stage)" @select="selectStage(stage)" /> </ul> </nav> <section class="stage-events overflow-auto"> <gl-loading-icon v-show="isLoadingStage" size="lg" /> - <template v-if="currentStage && !currentStage.isUserAllowed"> + <template v-if="displayNoAccess"> <gl-empty-state class="js-empty-state" :title="__('You need permission.')" @@ -263,19 +227,19 @@ export default { /> </template> <template v-else> - <template v-if="currentStage && isEmptyStage && !isLoadingStage"> + <template v-if="displayNotEnoughData"> <gl-empty-state class="js-empty-state" - :description="currentStage.emptyStageText" + :description="selectedStage.emptyStageText" :svg-path="noDataSvgPath" :title="__('We don\'t have enough data to show this stage.')" /> </template> - <template v-if="state.events.length && !isLoadingStage && !isEmptyStage"> + <template v-if="displayStageEvents"> <component - :is="currentStage.component" - :stage="currentStage" - :items="state.events" + :is="selectedStage.component" + :stage="selectedStage" + :items="selectedStageEvents" /> </template> </template> diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js new file mode 100644 index 00000000000..d79de207afe --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/constants.js @@ -0,0 +1 @@ +export const DEFAULT_DAYS_TO_DISPLAY = 30; diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js deleted file mode 100644 index d7fcda24352..00000000000 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js +++ /dev/null @@ -1,35 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -export default class CycleAnalyticsService { - constructor(options) { - this.axios = axios.create({ - baseURL: options.requestPath, - }); - } - - fetchCycleAnalyticsData(options = { startDate: 30 }) { - const { startDate, projectIds } = options; - - return this.axios - .get('', { - params: { - 'cycle_analytics[start_date]': startDate, - 'cycle_analytics[project_ids]': projectIds, - }, - }) - .then((x) => x.data); - } - - fetchStageData(options) { - const { stage, startDate, projectIds } = options; - - return this.axios - .get(`events/${stage.name}.json`, { - params: { - 'cycle_analytics[start_date]': startDate, - 'cycle_analytics[project_ids]': projectIds, - }, - }) - .then((x) => x.data); - } -} diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js deleted file mode 100644 index 24ad6ef4c88..00000000000 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js +++ /dev/null @@ -1,112 +0,0 @@ -/* eslint-disable no-param-reassign */ - -import { dasherize } from '../lib/utils/text_utility'; -import { __ } from '../locale'; -import DEFAULT_EVENT_OBJECTS from './default_event_objects'; - -const EMPTY_STAGE_TEXTS = { - issue: __( - 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', - ), - plan: __( - 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', - ), - code: __( - 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', - ), - test: __( - 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', - ), - review: __( - 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', - ), - staging: __( - 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', - ), -}; - -export default { - state: { - summary: '', - stats: '', - analytics: '', - events: [], - stages: [], - }, - setCycleAnalyticsData(data) { - this.state = Object.assign(this.state, this.decorateData(data)); - }, - decorateData(data) { - const newData = {}; - - newData.stages = data.stats || []; - newData.summary = data.summary || []; - - newData.summary.forEach((item) => { - item.value = item.value || '-'; - }); - - newData.stages.forEach((item) => { - const stageSlug = dasherize(item.name.toLowerCase()); - item.active = false; - item.isUserAllowed = data.permissions[stageSlug]; - item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; - item.component = `stage-${stageSlug}-component`; - item.slug = stageSlug; - }); - newData.analytics = data; - return newData; - }, - setLoadingState(state) { - this.state.isLoading = state; - }, - setErrorState(state) { - this.state.hasError = state; - }, - deactivateAllStages() { - this.state.stages.forEach((stage) => { - stage.active = false; - }); - }, - setActiveStage(stage) { - this.deactivateAllStages(); - stage.active = true; - }, - setStageEvents(events, stage) { - this.state.events = this.decorateEvents(events, stage); - }, - decorateEvents(events, stage) { - const newEvents = []; - - events.forEach((item) => { - if (!item) return; - - const eventItem = { ...DEFAULT_EVENT_OBJECTS[stage.slug], ...item }; - - eventItem.totalTime = eventItem.total_time; - - if (eventItem.author) { - eventItem.author.webUrl = eventItem.author.web_url; - eventItem.author.avatarUrl = eventItem.author.avatar_url; - } - - if (eventItem.created_at) eventItem.createdAt = eventItem.created_at; - if (eventItem.short_sha) eventItem.shortSha = eventItem.short_sha; - if (eventItem.commit_url) eventItem.commitUrl = eventItem.commit_url; - - delete eventItem.author.web_url; - delete eventItem.author.avatar_url; - delete eventItem.total_time; - delete eventItem.created_at; - delete eventItem.short_sha; - delete eventItem.commit_url; - - newEvents.push(eventItem); - }); - - return newEvents; - }, - currentActiveStage() { - return this.state.stages.find((stage) => stage.active); - }, -}; diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js index 42d6700fae1..00192cc61f8 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/cycle_analytics/index.js @@ -1,31 +1,29 @@ import Vue from 'vue'; import Translate from '../vue_shared/translate'; import CycleAnalytics from './components/base.vue'; -import CycleAnalyticsService from './cycle_analytics_service'; -import CycleAnalyticsStore from './cycle_analytics_store'; +import createStore from './store'; Vue.use(Translate); -const createCycleAnalyticsService = (requestPath) => - new CycleAnalyticsService({ - requestPath, - }); - export default () => { + const store = createStore(); const el = document.querySelector('#js-cycle-analytics'); - const { noAccessSvgPath, noDataSvgPath } = el.dataset; + const { noAccessSvgPath, noDataSvgPath, requestPath } = el.dataset; + + store.dispatch('initializeVsa', { + requestPath, + }); // eslint-disable-next-line no-new new Vue({ el, name: 'CycleAnalytics', + store, render: (createElement) => createElement(CycleAnalytics, { props: { noDataSvgPath, noAccessSvgPath, - store: CycleAnalyticsStore, - service: createCycleAnalyticsService(el.dataset.requestPath), }, }), }); diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js new file mode 100644 index 00000000000..fe3c6d6b3ba --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/store/actions.js @@ -0,0 +1,51 @@ +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; +import * as types from './mutation_types'; + +export const fetchCycleAnalyticsData = ({ + state: { requestPath, startDate }, + dispatch, + commit, +}) => { + commit(types.REQUEST_CYCLE_ANALYTICS_DATA); + + return axios + .get(requestPath, { + params: { 'cycle_analytics[start_date]': startDate }, + }) + .then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data)) + .then(() => dispatch('setSelectedStage')) + .then(() => dispatch('fetchStageData')) + .catch(() => { + commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR); + createFlash({ + message: __('There was an error while fetching value stream analytics data.'), + }); + }); +}; + +export const fetchStageData = ({ state: { requestPath, selectedStage, startDate }, commit }) => { + commit(types.REQUEST_STAGE_DATA); + + return axios + .get(`${requestPath}/events/${selectedStage.name}.json`, { + params: { 'cycle_analytics[start_date]': startDate }, + }) + .then(({ data }) => commit(types.RECEIVE_STAGE_DATA_SUCCESS, data)) + .catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR)); +}; + +export const setSelectedStage = ({ commit, state: { stages } }, selectedStage = null) => { + const stage = selectedStage || stages[0]; + commit(types.SET_SELECTED_STAGE, stage); +}; + +export const setDateRange = ({ commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) => + commit(types.SET_DATE_RANGE, { startDate }); + +export const initializeVsa = ({ commit, dispatch }, initialData = {}) => { + commit(types.INITIALIZE_VSA, initialData); + return dispatch('fetchCycleAnalyticsData'); +}; diff --git a/app/assets/javascripts/cycle_analytics/store/index.js b/app/assets/javascripts/cycle_analytics/store/index.js new file mode 100644 index 00000000000..ab47538dcf5 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/store/index.js @@ -0,0 +1,21 @@ +/** + * While we are in the process implementing group level features at the project level + * we will use a simplified vuex store for the project level, eventually this can be + * replaced with the store at ee/app/assets/javascripts/analytics/cycle_analytics/store/index.js + * once we have enough of the same features implemented across the project and group level + */ + +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + mutations, + state, + }); diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js new file mode 100644 index 00000000000..00aae49ae9f --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js @@ -0,0 +1,12 @@ +export const INITIALIZE_VSA = 'INITIALIZE_VSA'; + +export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; +export const SET_DATE_RANGE = 'SET_DATE_RANGE'; + +export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA'; +export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS'; +export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ERROR'; + +export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA'; +export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS'; +export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR'; diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js new file mode 100644 index 00000000000..8fd5c78339a --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/store/mutations.js @@ -0,0 +1,52 @@ +import { decorateData, decorateEvents } from '../utils'; +import * as types from './mutation_types'; + +export default { + [types.INITIALIZE_VSA](state, { requestPath }) { + state.requestPath = requestPath; + }, + [types.SET_SELECTED_STAGE](state, stage) { + state.isLoadingStage = true; + state.selectedStage = stage; + state.isLoadingStage = false; + }, + [types.SET_DATE_RANGE](state, { startDate }) { + state.startDate = startDate; + }, + [types.REQUEST_CYCLE_ANALYTICS_DATA](state) { + state.isLoading = true; + state.stages = []; + state.hasError = false; + }, + [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) { + state.isLoading = false; + const { stages, summary } = decorateData(data); + state.stages = stages; + state.summary = summary; + state.hasError = false; + }, + [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) { + state.isLoading = false; + state.stages = []; + state.hasError = true; + }, + [types.REQUEST_STAGE_DATA](state) { + state.isLoadingStage = true; + state.isEmptyStage = false; + state.selectedStageEvents = []; + state.hasError = false; + }, + [types.RECEIVE_STAGE_DATA_SUCCESS](state, { events = [] }) { + const { selectedStage } = state; + state.isLoadingStage = false; + state.isEmptyStage = !events.length; + state.selectedStageEvents = decorateEvents(events, selectedStage); + state.hasError = false; + }, + [types.RECEIVE_STAGE_DATA_ERROR](state) { + state.isLoadingStage = false; + state.isEmptyStage = true; + state.selectedStageEvents = []; + state.hasError = true; + }, +}; diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js new file mode 100644 index 00000000000..5db4e1878a9 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/store/state.js @@ -0,0 +1,17 @@ +import { DEFAULT_DAYS_TO_DISPLAY } from '../constants'; + +export default () => ({ + requestPath: '', + startDate: DEFAULT_DAYS_TO_DISPLAY, + stages: [], + summary: [], + analytics: [], + stats: [], + selectedStage: {}, + selectedStageEvents: [], + medians: {}, + hasError: false, + isLoading: false, + isLoadingStage: false, + isEmptyStage: false, +}); diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js new file mode 100644 index 00000000000..3afe4b021be --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/utils.js @@ -0,0 +1,63 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { dasherize } from '~/lib/utils/text_utility'; +import { __ } from '../locale'; +import DEFAULT_EVENT_OBJECTS from './default_event_objects'; + +const EMPTY_STAGE_TEXTS = { + issue: __( + 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', + ), + plan: __( + 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', + ), + code: __( + 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', + ), + test: __( + 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', + ), + review: __( + 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', + ), + staging: __( + 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', + ), +}; + +/** + * These `decorate` methods will be removed when me migrate to the + * new table layout https://gitlab.com/gitlab-org/gitlab/-/issues/326704 + */ +const mapToEvent = (event, stage) => { + return convertObjectPropsToCamelCase( + { + ...DEFAULT_EVENT_OBJECTS[stage.slug], + ...event, + }, + { deep: true }, + ); +}; + +export const decorateEvents = (events, stage) => events.map((event) => mapToEvent(event, stage)); + +const mapToStage = (permissions, item) => { + const slug = dasherize(item.name.toLowerCase()); + return { + ...item, + slug, + active: false, + isUserAllowed: permissions[slug], + emptyStageText: EMPTY_STAGE_TEXTS[slug], + component: `stage-${slug}-component`, + }; +}; + +const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' }); + +export const decorateData = (data = {}) => { + const { permissions, stats, summary } = data; + return { + stages: stats?.map((item) => mapToStage(permissions, item)) || [], + summary: summary?.map((item) => mapToSummary(item)) || [], + }; +}; diff --git a/app/assets/javascripts/deploy_freeze/store/mutations.js b/app/assets/javascripts/deploy_freeze/store/mutations.js index e62000c007c..fdd1ea6e32e 100644 --- a/app/assets/javascripts/deploy_freeze/store/mutations.js +++ b/app/assets/javascripts/deploy_freeze/store/mutations.js @@ -7,7 +7,7 @@ const formatTimezoneName = (freezePeriod, timezoneList) => cron_timezone: { formattedTimezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone) ?.name, - identifier: freezePeriod.cronTimezone, + identifier: freezePeriod.cron_timezone, }, }); diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue index af7c391ab70..7bc1eb5d652 100644 --- a/app/assets/javascripts/deploy_keys/components/action_btn.vue +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -1,10 +1,10 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import eventHub from '../eventhub'; export default { components: { - GlLoadingIcon, + GlButton, }, props: { deployKey: { @@ -15,10 +15,20 @@ export default { type: String, required: true, }, - btnCssClass: { + category: { type: String, required: false, - default: 'btn-default', + default: 'tertiary', + }, + variant: { + type: String, + required: false, + default: 'default', + }, + icon: { + type: String, + required: false, + default: '', }, }, data() { @@ -39,13 +49,14 @@ export default { </script> <template> - <button - :class="[{ disabled: isLoading }, btnCssClass]" - :disabled="isLoading" + <gl-button + :category="category" + :variant="variant" + :icon="icon" + :loading="isLoading" class="btn" @click="doAction" > <slot></slot> - <gl-loading-icon v-if="isLoading" :inline="true" /> - </button> + </gl-button> </template> diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 425cca13ae8..02c57164f47 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -6,10 +6,12 @@ import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import eventHub from '../eventhub'; import DeployKeysService from '../service'; import DeployKeysStore from '../store'; +import ConfirmModal from './confirm_modal.vue'; import KeysPanel from './keys_panel.vue'; export default { components: { + ConfirmModal, KeysPanel, NavigationTabs, GlLoadingIcon, @@ -30,6 +32,9 @@ export default { currentTab: 'enabled_keys', isLoading: false, store: new DeployKeysStore(), + removeKey: () => {}, + cancel: () => {}, + confirmModalVisible: false, }; }, scopes: { @@ -61,16 +66,16 @@ export default { this.service = new DeployKeysService(this.endpoint); eventHub.$on('enable.key', this.enableKey); - eventHub.$on('remove.key', this.disableKey); - eventHub.$on('disable.key', this.disableKey); + eventHub.$on('remove.key', this.confirmRemoveKey); + eventHub.$on('disable.key', this.confirmRemoveKey); }, mounted() { this.fetchKeys(); }, beforeDestroy() { eventHub.$off('enable.key', this.enableKey); - eventHub.$off('remove.key', this.disableKey); - eventHub.$off('disable.key', this.disableKey); + eventHub.$off('remove.key', this.confirmRemoveKey); + eventHub.$off('disable.key', this.confirmRemoveKey); }, methods: { onChangeTab(tab) { @@ -97,19 +102,20 @@ export default { .then(this.fetchKeys) .catch(() => new Flash(s__('DeployKeys|Error enabling deploy key'))); }, - disableKey(deployKey, callback) { - if ( - // eslint-disable-next-line no-alert - window.confirm(s__('DeployKeys|You are going to remove this deploy key. Are you sure?')) - ) { + confirmRemoveKey(deployKey, callback) { + const hideModal = () => { + this.confirmModalVisible = false; + callback?.(); + }; + this.removeKey = () => { this.service .disableKey(deployKey.id) .then(this.fetchKeys) - .then(callback) + .then(hideModal) .catch(() => new Flash(s__('DeployKeys|Error removing deploy key'))); - } else { - callback(); - } + }; + this.cancel = hideModal; + this.confirmModalVisible = true; }, }, }; @@ -117,6 +123,7 @@ export default { <template> <div class="gl-mb-3 deploy-keys"> + <confirm-modal :visible="confirmModalVisible" @remove="removeKey" @cancel="cancel" /> <gl-loading-icon v-if="isLoading && !hasKeys" :label="s__('DeployKeys|Loading deploy keys')" @@ -124,8 +131,12 @@ export default { /> <template v-else-if="hasKeys"> <div class="top-area scrolling-tabs-container inner-page-scroll-tabs"> - <div class="fade-left"><gl-icon name="chevron-lg-left" :size="12" /></div> - <div class="fade-right"><gl-icon name="chevron-lg-right" :size="12" /></div> + <div class="fade-left"> + <gl-icon name="chevron-lg-left" :size="12" /> + </div> + <div class="fade-right"> + <gl-icon name="chevron-lg-right" :size="12" /> + </div> <navigation-tabs :tabs="tabs" scope="deployKeys" @onChangeTab="onChangeTab" /> </div> @@ -134,7 +145,7 @@ export default { :keys="keys[currentTab]" :store="store" :endpoint="endpoint" - data-qa-selector="project_deploy_keys" + data-qa-selector="project_deploy_keys_container" /> </template> </div> diff --git a/app/assets/javascripts/deploy_keys/components/confirm_modal.vue b/app/assets/javascripts/deploy_keys/components/confirm_modal.vue new file mode 100644 index 00000000000..1932435c42a --- /dev/null +++ b/app/assets/javascripts/deploy_keys/components/confirm_modal.vue @@ -0,0 +1,46 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlModal, + }, + props: { + visible: { + type: Boolean, + required: false, + default: false, + }, + }, + i18n: { + body: __( + 'Are you sure you want to remove this deploy key? If anything is still using this key, it will stop working.', + ), + }, + modalOptions: { + title: __('Do you want to remove this deploy key?'), + actionPrimary: { + text: __('Remove deploy key'), + attributes: [{ variant: 'danger' }], + }, + actionSecondary: { + text: __('Cancel'), + attributes: [{ category: 'tertiary' }], + }, + static: true, + modalId: 'confirm-remove-deploy-key', + }, +}; +</script> +<template> + <gl-modal + v-bind="$options.modalOptions" + :visible="visible" + @primary="$emit('remove')" + @secondary="$emit('cancel')" + @hidden="$emit('cancel')" + > + {{ $options.i18n.body }} + </gl-modal> +</template> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index e70ca18bb71..8a7d3430063 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlLink, GlTooltipDirective, GlButton } from '@gitlab/ui'; import { head, tail } from 'lodash'; import { s__, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -9,7 +9,9 @@ import actionBtn from './action_btn.vue'; export default { components: { actionBtn, + GlButton, GlIcon, + GlLink, }, directives: { GlTooltip: GlTooltipDirective, @@ -111,9 +113,9 @@ export default { <div class="gl-responsive-table-row deploy-key"> <div class="table-section section-40"> <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div> - <div class="table-mobile-content qa-key"> - <strong class="title qa-key-title"> {{ deployKey.title }} </strong> - <div class="fingerprint" data-qa-selector="key_md5_fingerprint"> + <div class="table-mobile-content" data-qa-selector="key_container"> + <strong class="title" data-qa-selector="key_title_content"> {{ deployKey.title }} </strong> + <div class="fingerprint" data-qa-selector="key_md5_fingerprint_content"> {{ __('MD5') }}:{{ deployKey.fingerprint }} </div> <div class="fingerprint">{{ __('SHA256') }}:{{ deployKey.fingerprint_sha256 }}</div> @@ -123,15 +125,15 @@ export default { <div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Project usage') }}</div> <div class="table-mobile-content deploy-project-list"> <template v-if="projects.length > 0"> - <a + <gl-link v-gl-tooltip :title="projectTooltipTitle(firstProject)" class="label deploy-project-label" > <span> {{ firstProject.project.full_name }} </span> <gl-icon :name="firstProject.can_push ? 'lock-open' : 'lock'" /> - </a> - <a + </gl-link> + <gl-link v-if="isExpandable" v-gl-tooltip :title="restProjectsTooltip" @@ -139,8 +141,8 @@ export default { @click="toggleExpanded" > <span>{{ restProjectsLabel }}</span> - </a> - <a + </gl-link> + <gl-link v-for="deployKeysProject in restProjects" v-else-if="isExpanded" :key="deployKeysProject.project.full_path" @@ -151,7 +153,7 @@ export default { > <span> {{ deployKeysProject.project.full_name }} </span> <gl-icon :name="deployKeysProject.can_push ? 'lock-open' : 'lock'" /> - </a> + </gl-link> </template> <span v-else class="text-secondary">{{ __('None') }}</span> </div> @@ -166,41 +168,43 @@ export default { </div> <div class="table-section section-15 table-button-footer deploy-key-actions"> <div class="btn-group table-action-buttons"> - <action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable"> + <action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary"> {{ __('Enable') }} </action-btn> - <a + <gl-button v-if="deployKey.can_edit" v-gl-tooltip :href="editDeployKeyPath" :title="__('Edit')" - class="btn btn-default text-secondary" + :aria-label="__('Edit')" data-container="body" - > - <gl-icon name="pencil" /> - </a> + icon="pencil" + category="secondary" + /> <action-btn v-if="isRemovable" v-gl-tooltip :deploy-key="deployKey" :title="__('Remove')" - btn-css-class="btn-danger" + :aria-label="__('Remove')" + category="primary" + variant="danger" + icon="remove" type="remove" data-container="body" - > - <gl-icon name="remove" /> - </action-btn> + /> <action-btn v-else-if="isEnabled" v-gl-tooltip :deploy-key="deployKey" :title="__('Disable')" - btn-css-class="btn-warning" + :aria-label="__('Disable')" type="disable" data-container="body" - > - <gl-icon name="cancel" /> - </action-btn> + icon="cancel" + category="primary" + variant="danger" + /> </div> </div> </div> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index fb25d3618ab..336ce714a05 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -115,12 +115,13 @@ export default { </template> </markdown-field> <slot name="resolve-checkbox"></slot> - <div class="note-form-actions gl-display-flex gl-justify-content-space-between"> + <div class="note-form-actions gl-display-flex"> <gl-button ref="submitButton" :disabled="!hasValue || isSaving" + class="gl-mr-3 gl-w-auto!" category="primary" - variant="success" + variant="confirm" type="submit" data-track-event="click_button" data-qa-selector="save_comment_button" @@ -128,9 +129,14 @@ export default { > {{ buttonText }} </gl-button> - <gl-button ref="cancelButton" variant="default" category="primary" @click="cancelComment">{{ - __('Cancel') - }}</gl-button> + <gl-button + ref="cancelButton" + class="gl-w-auto!" + variant="default" + category="primary" + @click="cancelComment" + >{{ __('Cancel') }}</gl-button + > </div> <gl-modal ref="cancelCommentModal" diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 04d80dc0069..ad557f64ce4 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -333,7 +333,7 @@ export default { ghostClass: 'gl-visibility-hidden', }, i18n: { - dropzoneDescriptionText: __('Drop or %{linkStart}upload%{linkEnd} designs to attach'), + dropzoneDescriptionText: __('Drag your designs here or %{linkStart}click to upload%{linkEnd}.'), }, }; </script> diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 7c610968209..6a3f5993a22 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -3,6 +3,8 @@ import { GlLoadingIcon, GlPagination, GlSprintf } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Mousetrap from 'mousetrap'; import { mapState, mapGetters, mapActions } from 'vuex'; +import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; +import api from '~/api'; import { keysFor, MR_PREVIOUS_FILE_IN_DIFF, @@ -16,7 +18,6 @@ import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import notesEventHub from '../../notes/event_hub'; import { @@ -30,6 +31,15 @@ import { ALERT_OVERFLOW_HIDDEN, ALERT_MERGE_CONFLICT, ALERT_COLLAPSED_FILES, + INLINE_DIFF_VIEW_TYPE, + TRACKING_DIFF_VIEW_INLINE, + TRACKING_DIFF_VIEW_PARALLEL, + TRACKING_FILE_BROWSER_TREE, + TRACKING_FILE_BROWSER_LIST, + TRACKING_WHITESPACE_SHOW, + TRACKING_WHITESPACE_HIDE, + TRACKING_SINGLE_FILE_MODE, + TRACKING_MULTIPLE_FILES_MODE, } from '../constants'; import { reviewStatuses } from '../utils/file_reviews'; @@ -59,8 +69,9 @@ export default { PanelResizer, GlPagination, GlSprintf, + DynamicScroller, + DynamicScrollerItem, }, - mixins: [glFeatureFlagsMixin()], alerts: { ALERT_OVERFLOW_HIDDEN, ALERT_MERGE_CONFLICT, @@ -183,8 +194,15 @@ export default { 'hasConflicts', 'viewDiffsFileByFile', 'mrReviews', + 'renderTreeList', + 'showWhitespace', + ]), + ...mapGetters('diffs', [ + 'whichCollapsedTypes', + 'isParallelView', + 'currentDiffIndex', + 'isVirtualScrollingEnabled', ]), - ...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']), ...mapGetters('batchComments', ['draftsCount']), ...mapGetters(['isNotesFetched', 'getNoteableData']), diffs() { @@ -305,6 +323,32 @@ export default { if (id && id.indexOf('#note') !== 0) { this.setHighlightedRow(id.split('diff-content').pop().slice(1)); } + + if (window.gon?.features?.diffSettingsUsageData) { + if (this.renderTreeList) { + api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_TREE); + } else { + api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_LIST); + } + + if (this.diffViewType === INLINE_DIFF_VIEW_TYPE) { + api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_INLINE); + } else { + api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_PARALLEL); + } + + if (this.showWhitespace) { + api.trackRedisHllUserEvent(TRACKING_WHITESPACE_SHOW); + } else { + api.trackRedisHllUserEvent(TRACKING_WHITESPACE_HIDE); + } + + if (this.viewDiffsFileByFile) { + api.trackRedisHllUserEvent(TRACKING_SINGLE_FILE_MODE); + } else { + api.trackRedisHllUserEvent(TRACKING_MULTIPLE_FILES_MODE); + } + } }, beforeCreate() { diffsApp.instrument(); @@ -523,17 +567,41 @@ export default { <commit-widget v-if="commit" :commit="commit" :collapsible="false" /> <div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div> <template v-else-if="renderDiffFiles"> - <diff-file - v-for="(file, index) in diffs" - :key="file.newPath" - :file="file" - :reviewed="fileReviews[file.id]" - :is-first-file="index === 0" - :is-last-file="index === diffFilesLength - 1" - :help-page-path="helpPagePath" - :can-current-user-fork="canCurrentUserFork" - :view-diffs-file-by-file="viewDiffsFileByFile" - /> + <dynamic-scroller + v-if="isVirtualScrollingEnabled" + :items="diffs" + :min-item-size="70" + :buffer="1000" + :use-transform="false" + page-mode + > + <template #default="{ item, index, active }"> + <dynamic-scroller-item :item="item" :active="active"> + <diff-file + :file="item" + :reviewed="fileReviews[item.id]" + :is-first-file="index === 0" + :is-last-file="index === diffFilesLength - 1" + :help-page-path="helpPagePath" + :can-current-user-fork="canCurrentUserFork" + :view-diffs-file-by-file="viewDiffsFileByFile" + /> + </dynamic-scroller-item> + </template> + </dynamic-scroller> + <template v-else> + <diff-file + v-for="(file, index) in diffs" + :key="file.new_path" + :file="file" + :reviewed="fileReviews[file.id]" + :is-first-file="index === 0" + :is-last-file="index === diffFilesLength - 1" + :help-page-path="helpPagePath" + :can-current-user-fork="canCurrentUserFork" + :view-diffs-file-by-file="viewDiffsFileByFile" + /> + </template> <div v-if="showFileByFileNavigation" data-testid="file-by-file-navigation" diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index bc0f2fb0b69..820c64a9502 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -138,7 +138,7 @@ export default { /> </div> <div class="commit-detail flex-list"> - <div class="commit-content qa-commit-content"> + <div class="commit-content" data-qa-selector="commit_content"> <a :href="commit.commit_url" class="commit-row-message item-title" @@ -173,7 +173,7 @@ export default { <pre v-if="commit.description_html" :class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }" - class="commit-row-description gl-mb-3 text-dark" + class="commit-row-description gl-mb-3 gl-text-body" v-html="commitDescription" ></pre> </div> diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue index 2c249f71091..6c5973b7c28 100644 --- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue +++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue @@ -1,11 +1,12 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { GlDropdown, GlDropdownItem, + GlDropdownDivider, TimeAgo, }, props: { @@ -24,34 +25,38 @@ export default { <template> <gl-dropdown :text="selectedVersionName" data-qa-selector="dropdown_content"> - <gl-dropdown-item - v-for="version in versions" - :key="version.id" - :class="{ - 'is-active': version.selected, - }" - :is-check-item="true" - :is-checked="version.selected" - :href="version.href" - > - <div> - <strong> - {{ version.versionName }} - <template v-if="version.isHead">{{ s__('DiffsCompareBaseBranch|(HEAD)') }}</template> - <template v-else-if="version.isBase">{{ s__('DiffsCompareBaseBranch|(base)') }}</template> - </strong> - </div> - <div> - <small class="commit-sha"> {{ version.short_commit_sha }} </small> - </div> - <div> - <small> - <template v-if="version.commitsText"> - {{ version.commitsText }} - </template> - <time-ago v-if="version.created_at" :time="version.created_at" class="js-timeago" /> - </small> - </div> - </gl-dropdown-item> + <template v-for="version in versions"> + <gl-dropdown-divider v-if="version.addDivider" :key="version.id" /> + <gl-dropdown-item + :key="version.id" + :class="{ + 'is-active': version.selected, + }" + :is-check-item="true" + :is-checked="version.selected" + :href="version.href" + > + <div> + <strong> + {{ version.versionName }} + <template v-if="version.isHead">{{ s__('DiffsCompareBaseBranch|(HEAD)') }}</template> + <template v-else-if="version.isBase">{{ + s__('DiffsCompareBaseBranch|(base)') + }}</template> + </strong> + </div> + <div> + <small class="commit-sha"> {{ version.short_commit_sha }} </small> + </div> + <div> + <small> + <template v-if="version.commitsText"> + {{ version.commitsText }} + </template> + <time-ago v-if="version.created_at" :time="version.created_at" class="js-timeago" /> + </small> + </div> + </gl-dropdown-item> + </template> </gl-dropdown> </template> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 7526c5347f7..e2a1f7236c5 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -142,7 +142,7 @@ export default { </gl-button-group> </div> <gl-sprintf - v-else-if="hasSourceVersions" + v-else-if="!commit && hasSourceVersions" class="d-flex align-items-center compare-versions-container" :message="s__('MergeRequest|Compare %{target} and %{source}')" > diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 663d2bb3cf8..283dbc6031c 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -49,9 +49,7 @@ export default { }, }, computed: { - ...mapState({ - projectPath: (state) => state.diffs.projectPath, - }), + ...mapState('diffs', ['projectPath']), ...mapGetters('diffs', [ 'isInlineView', 'isParallelView', diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index bdbc13a38c4..ce867dbb9e0 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -5,6 +5,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { hasDiff } from '~/helpers/diffs_helper'; import { diffViewerErrors } from '~/ide/constants'; +import { scrollToElement } from '~/lib/utils/common_utils'; import { sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import notesEventHub from '../../notes/event_hub'; @@ -82,7 +83,7 @@ export default { computed: { ...mapState('diffs', ['currentDiffFileId', 'codequalityDiff']), ...mapGetters(['isNotesFetched']), - ...mapGetters('diffs', ['getDiffFileDiscussions']), + ...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled']), viewBlobHref() { return escape(this.file.view_path); }, @@ -148,10 +149,8 @@ export default { return loggedIn && featureOn; }, - hasCodequalityChanges() { - return ( - this.codequalityDiff?.files && this.codequalityDiff?.files[this.file.file_path]?.length > 0 - ); + codequalityDiffForFile() { + return this.codequalityDiff?.files?.[this.file.file_path] || []; }, }, watch: { @@ -235,15 +234,20 @@ export default { eventHub.$emit(event); }); }, - handleToggle() { - const currentCollapsedFlag = this.isCollapsed; + handleToggle({ viaUserInteraction = false } = {}) { + const collapsingNow = !this.isCollapsed; + const contentElement = this.$el.querySelector(`#diff-content-${this.file.file_hash}`); this.setFileCollapsedByUser({ filePath: this.file.file_path, - collapsed: !currentCollapsedFlag, + collapsed: collapsingNow, }); - if (!this.hasDiff && currentCollapsedFlag) { + if (collapsingNow && viaUserInteraction && contentElement) { + scrollToElement(contentElement, { duration: 1 }); + } + + if (!this.hasDiff && !collapsingNow) { this.requestDiff(); } }, @@ -286,6 +290,7 @@ export default { 'is-active': currentDiffFileId === file.file_hash, 'comments-disabled': Boolean(file.brokenSymlink), 'has-body': showBody, + 'is-virtual-scrolling': isVirtualScrollingEnabled, }" :data-path="file.new_path" class="diff-file file-holder gl-border-none" @@ -299,10 +304,10 @@ export default { :add-merge-request-buttons="true" :view-diffs-file-by-file="viewDiffsFileByFile" :show-local-file-reviews="showLocalFileReviews" - :has-codequality-changes="hasCodequalityChanges" + :codequality-diff="codequalityDiffForFile" class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100" :class="hasBodyClasses.header" - @toggleFile="handleToggle" + @toggleFile="handleToggle({ viaUserInteraction: true })" @showForkMessage="showForkMessage" /> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 3b4e21ab61b..676c9a3c7bc 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -96,10 +96,10 @@ export default { required: false, default: false, }, - hasCodequalityChanges: { - type: Boolean, + codequalityDiff: { + type: Array, required: false, - default: false, + default: () => [], }, }, data() { @@ -333,7 +333,12 @@ export default { data-track-property="diff_copy_file" /> - <code-quality-badge v-if="hasCodequalityChanges" class="gl-mr-2" /> + <code-quality-badge + v-if="codequalityDiff.length" + :file-name="filePath" + :codequality-diff="codequalityDiff" + class="gl-mr-2" + /> <small v-if="isModeChanged" ref="fileMode" class="mr-1"> {{ diffFile.a_mode }} → {{ diffFile.b_mode }} diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 51da1966630..c907b5dffaf 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -207,14 +207,14 @@ export default { </div> <note-form ref="noteForm" - :is-editing="true" + :is-editing="false" :line-code="line.line_code" :line="line" :lines="commentLines" :help-page-path="helpPagePath" :diff-file="diffFile" :show-suggest-popover="showSuggestPopover" - save-button-title="Comment" + :save-button-title="__('Comment')" class="diff-comment-form gl-mt-3" @handleFormUpdateAddToReview="addToReview" @cancelForm="handleCancelCommentForm" diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index 8d398a2ded4..d4a1a9e0e46 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -204,27 +204,33 @@ export default { <template v-if="line.left && line.left.type !== $options.CONFLICT_MARKER"> <div :class="classNameMapCellLeft" - data-testid="leftLineNumber" + data-testid="left-line-number" class="diff-td diff-line-num" + data-qa-selector="new_diff_line_link" > <template v-if="!isLeftConflictMarker"> <span v-if="shouldRenderCommentButton && !line.hasDiscussionsLeft" v-gl-tooltip - data-testid="leftCommentButton" class="add-diff-note tooltip-wrapper" :title="addCommentTooltipLeft" > - <button - :draggable="glFeatures.dragCommentSelection" + <div + data-testid="left-comment-button" + role="button" + tabindex="0" + :draggable="!line.left.commentsDisabled && glFeatures.dragCommentSelection" type="button" - class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button qa-diff-comment" + class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button" data-qa-selector="diff_comment_button" :class="{ 'gl-cursor-grab': dragging }" :disabled="line.left.commentsDisabled" - @click="handleCommentButton(line.left)" - @dragstart="onDragStart({ ...line.left, index })" - ></button> + :aria-disabled="line.left.commentsDisabled" + @click="!line.left.commentsDisabled && handleCommentButton(line.left)" + @keydown.enter="!line.left.commentsDisabled && handleCommentButton(line.left)" + @keydown.space="!line.left.commentsDisabled && handleCommentButton(line.left)" + @dragstart="!line.left.commentsDisabled && onDragStart({ ...line.left, index })" + ></div> </span> </template> <a @@ -238,7 +244,7 @@ export default { v-if="line.hasDiscussionsLeft" :discussions="line.left.discussions" :discussions-expanded="line.left.discussionsExpanded" - data-testid="leftDiscussions" + data-testid="left-discussions" @toggleLineDiscussions=" toggleLineDiscussions({ lineCode: line.left.line_code, @@ -268,7 +274,7 @@ export default { :key="line.left.line_code" :class="[parallelViewLeftLineType, { parallel: !inline }]" class="diff-td line_content with-coverage left-side" - data-testid="leftContent" + data-testid="left-content" @mousedown="handleParallelLineMouseDown" > <strong v-if="isLeftConflictMarker">{{ conflictText(line.left) }}</strong> @@ -277,7 +283,7 @@ export default { </template> <template v-else-if="!inline || (line.left && line.left.type === $options.CONFLICT_MARKER)"> <div - data-testid="leftEmptyCell" + data-testid="left-empty-cell" class="diff-td diff-line-num old_line empty-cell" :class="emptyCellLeftClassMap" > @@ -313,19 +319,24 @@ export default { <span v-if="shouldRenderCommentButton && !line.hasDiscussionsRight" v-gl-tooltip - data-testid="rightCommentButton" class="add-diff-note tooltip-wrapper" :title="addCommentTooltipRight" > - <button - :draggable="glFeatures.dragCommentSelection" + <div + data-testid="right-comment-button" + role="button" + tabindex="0" + :draggable="!line.right.commentsDisabled && glFeatures.dragCommentSelection" type="button" - class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button qa-diff-comment" + class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button" :class="{ 'gl-cursor-grab': dragging }" :disabled="line.right.commentsDisabled" - @click="handleCommentButton(line.right)" - @dragstart="onDragStart({ ...line.right, index })" - ></button> + :aria-disabled="line.right.commentsDisabled" + @click="!line.right.commentsDisabled && handleCommentButton(line.right)" + @keydown.enter="!line.right.commentsDisabled && handleCommentButton(line.right)" + @keydown.space="!line.right.commentsDisabled && handleCommentButton(line.right)" + @dragstart="!line.right.commentsDisabled && onDragStart({ ...line.right, index })" + ></div> </span> </template> <a @@ -339,7 +350,7 @@ export default { v-if="line.hasDiscussionsRight" :discussions="line.right.discussions" :discussions-expanded="line.right.discussionsExpanded" - data-testid="rightDiscussions" + data-testid="right-discussions" @toggleLineDiscussions=" toggleLineDiscussions({ lineCode: line.right.line_code, @@ -381,7 +392,7 @@ export default { </template> <template v-else> <div - data-testid="rightEmptyCell" + data-testid="right-empty-cell" class="diff-td diff-line-num old_line empty-cell" :class="emptyCellRightClassMap" ></div> diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index 25403b1547e..f903fef72b7 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -177,7 +177,6 @@ export default { <a v-if="line.new_line" ref="lineNumberRefNew" - data-qa-selector="new_diff_line_link" :data-linenumber="line.new_line" :href="line.lineHref" @click="setHighlightedRow(line.lineCode)" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index 96946d0fd88..2d33926c8aa 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -193,7 +193,7 @@ export default { v-show="shouldShowCommentButtonLeft" ref="addDiffNoteButtonLeft" type="button" - class="add-diff-note note-button js-add-diff-note-button qa-diff-comment" + class="add-diff-note note-button js-add-diff-note-button" :disabled="line.left.commentsDisabled" :aria-label="addCommentTooltipLeft" @click="handleCommentButton(line.left)" @@ -251,7 +251,7 @@ export default { v-show="shouldShowCommentButtonRight" ref="addDiffNoteButtonRight" type="button" - class="add-diff-note note-button js-add-diff-note-button qa-diff-comment" + class="add-diff-note note-button js-add-diff-note-button" :disabled="line.right.commentsDisabled" :aria-label="addCommentTooltipRight" @click="handleCommentButton(line.right)" diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 0163f508fea..f0e15983336 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -114,3 +114,20 @@ export const CONFLICT_THEIR = 'conflict_their'; export const CONFLICT_MARKER = 'conflict_marker'; export const CONFLICT_MARKER_OUR = 'conflict_marker_our'; export const CONFLICT_MARKER_THEIR = 'conflict_marker_their'; + +// Tracking events +export const TRACKING_CLICK_DIFF_VIEW_SETTING = 'i_code_review_click_diff_view_setting'; +export const TRACKING_DIFF_VIEW_INLINE = 'i_code_review_diff_view_inline'; +export const TRACKING_DIFF_VIEW_PARALLEL = 'i_code_review_diff_view_parallel'; + +export const TRACKING_CLICK_FILE_BROWSER_SETTING = 'i_code_review_click_file_browser_setting'; +export const TRACKING_FILE_BROWSER_TREE = 'i_code_review_file_browser_tree_view'; +export const TRACKING_FILE_BROWSER_LIST = 'i_code_review_file_browser_list_view'; + +export const TRACKING_CLICK_WHITESPACE_SETTING = 'i_code_review_click_whitespace_setting'; +export const TRACKING_WHITESPACE_SHOW = 'i_code_review_diff_show_whitespace'; +export const TRACKING_WHITESPACE_HIDE = 'i_code_review_diff_hide_whitespace'; + +export const TRACKING_CLICK_SINGLE_FILE_SETTING = 'i_code_review_click_single_file_mode_setting'; +export const TRACKING_SINGLE_FILE_MODE = 'i_code_review_diff_single_file'; +export const TRACKING_MULTIPLE_FILES_MODE = 'i_code_review_diff_multiple_files'; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 428faf693b0..d0730e18228 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -1,5 +1,6 @@ import Cookies from 'js-cookie'; import Vue from 'vue'; +import api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { diffViewerModes } from '~/ide/constants'; import axios from '~/lib/utils/axios_utils'; @@ -36,6 +37,18 @@ import { DIFF_VIEW_FILE_BY_FILE, DIFF_VIEW_ALL_FILES, DIFF_FILE_BY_FILE_COOKIE_NAME, + TRACKING_CLICK_DIFF_VIEW_SETTING, + TRACKING_DIFF_VIEW_INLINE, + TRACKING_DIFF_VIEW_PARALLEL, + TRACKING_CLICK_FILE_BROWSER_SETTING, + TRACKING_FILE_BROWSER_TREE, + TRACKING_FILE_BROWSER_LIST, + TRACKING_CLICK_WHITESPACE_SETTING, + TRACKING_WHITESPACE_SHOW, + TRACKING_WHITESPACE_HIDE, + TRACKING_CLICK_SINGLE_FILE_SETTING, + TRACKING_SINGLE_FILE_MODE, + TRACKING_MULTIPLE_FILES_MODE, } from '../constants'; import eventHub from '../event_hub'; import { isCollapsed } from '../utils/diff_file'; @@ -352,6 +365,11 @@ export const setInlineDiffViewType = ({ commit }) => { Cookies.set(DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE); const url = mergeUrlParams({ view: INLINE_DIFF_VIEW_TYPE }, window.location.href); historyPushState(url); + + if (window.gon?.features?.diffSettingsUsageData) { + api.trackRedisHllUserEvent(TRACKING_CLICK_DIFF_VIEW_SETTING); + api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_INLINE); + } }; export const setParallelDiffViewType = ({ commit }) => { @@ -360,6 +378,11 @@ export const setParallelDiffViewType = ({ commit }) => { Cookies.set(DIFF_VIEW_COOKIE_NAME, PARALLEL_DIFF_VIEW_TYPE); const url = mergeUrlParams({ view: PARALLEL_DIFF_VIEW_TYPE }, window.location.href); historyPushState(url); + + if (window.gon?.features?.diffSettingsUsageData) { + api.trackRedisHllUserEvent(TRACKING_CLICK_DIFF_VIEW_SETTING); + api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_PARALLEL); + } }; export const showCommentForm = ({ commit }, { lineCode, fileHash }) => { @@ -527,6 +550,16 @@ export const setRenderTreeList = ({ commit }, renderTreeList) => { commit(types.SET_RENDER_TREE_LIST, renderTreeList); localStorage.setItem(TREE_LIST_STORAGE_KEY, renderTreeList); + + if (window.gon?.features?.diffSettingsUsageData) { + api.trackRedisHllUserEvent(TRACKING_CLICK_FILE_BROWSER_SETTING); + + if (renderTreeList) { + api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_TREE); + } else { + api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_LIST); + } + } }; export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = false }) => { @@ -540,6 +573,16 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals } notesEventHub.$emit('refetchDiffData'); + + if (window.gon?.features?.diffSettingsUsageData) { + api.trackRedisHllUserEvent(TRACKING_CLICK_WHITESPACE_SETTING); + + if (showWhitespace) { + api.trackRedisHllUserEvent(TRACKING_WHITESPACE_SHOW); + } else { + api.trackRedisHllUserEvent(TRACKING_WHITESPACE_HIDE); + } + } }; export const toggleFileFinder = ({ commit }, visible) => { @@ -754,6 +797,16 @@ export const setFileByFile = ({ state, commit }, { fileByFile }) => { commit(types.SET_FILE_BY_FILE, fileByFile); Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode); + if (window.gon?.features?.diffSettingsUsageData) { + api.trackRedisHllUserEvent(TRACKING_CLICK_SINGLE_FILE_SETTING); + + if (fileByFile) { + api.trackRedisHllUserEvent(TRACKING_SINGLE_FILE_MODE); + } else { + api.trackRedisHllUserEvent(TRACKING_MULTIPLE_FILES_MODE); + } + } + return axios .put(state.endpointUpdateUser, { view_diffs_file_by_file: fileByFile, diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index dec3f87b03e..0a9623c13a3 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -170,3 +170,6 @@ export function suggestionCommitMessage(state, _, rootState) { }, }); } + +export const isVirtualScrollingEnabled = (state) => + !state.viewDiffsFileByFile && window.gon?.features?.diffsVirtualScrolling; diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js index 01811e60caa..673ec821b58 100644 --- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js +++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js @@ -7,6 +7,9 @@ export const selectedTargetIndex = (state) => export const selectedSourceIndex = (state) => state.mergeRequestDiff.version_index; +export const selectedContextCommitsDiffs = (state) => + state.contextCommitsDiff && state.contextCommitsDiff.showing_context_commits_diff; + export const diffCompareDropdownTargetVersions = (state, getters) => { // startVersion only exists if the user has selected a version other // than "base" so if startVersion is null then base must be selected @@ -58,7 +61,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => { export const diffCompareDropdownSourceVersions = (state, getters) => { // Appended properties here are to make the compare_dropdown_layout easier to reason about - return state.mergeRequestDiffs.map((v, i) => { + const versions = state.mergeRequestDiffs.map((v, i) => { const isLatestVersion = i === 0; return { @@ -69,7 +72,20 @@ export const diffCompareDropdownSourceVersions = (state, getters) => { versionName: isLatestVersion ? __('latest version') : sprintf(__(`version %{versionIndex}`), { versionIndex: v.version_index }), - selected: v.version_index === getters.selectedSourceIndex, + selected: + v.version_index === getters.selectedSourceIndex && !getters.selectedContextCommitsDiffs, }; }); + + const { contextCommitsDiff } = state; + if (contextCommitsDiff) { + versions.push({ + href: contextCommitsDiff.diffs_path, + commitsText: n__(`%d commit`, `%d commits`, contextCommitsDiff.commits_count), + versionName: __('previously merged commits'), + selected: getters.selectedContextCommitsDiffs, + addDivider: state.mergeRequestDiffs.length > 0, + }); + } + return versions; }; diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js index 7e6fde320d2..a96c1207a04 100644 --- a/app/assets/javascripts/diffs/utils/diff_file.js +++ b/app/assets/javascripts/diffs/utils/diff_file.js @@ -1,4 +1,5 @@ import { truncateSha } from '~/lib/utils/text_utility'; +import { uuids } from '~/lib/utils/uuids'; import { DIFF_FILE_SYMLINK_MODE, @@ -7,7 +8,6 @@ import { DIFF_FILE_AUTOMATIC_COLLAPSE, } from '../constants'; import { getDerivedMergeRequestInformation } from './merge_request'; -import { uuids } from './uuids'; function fileSymlinkInformation(file, fileList) { const duplicates = fileList.filter((iteratedFile) => iteratedFile.file_hash === file.file_hash); diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 1f57d73d3d3..aa223270f2c 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -2,6 +2,7 @@ import dateFormat from 'dateformat'; import $ from 'jquery'; import Pikaday from 'pikaday'; +import initDatePicker from '~/behaviors/date_picker'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { __ } from '~/locale'; import boardsStore from './boards/stores/boards_store'; @@ -168,40 +169,10 @@ class DueDateSelect { export default class DueDateSelectors { constructor() { - this.initMilestoneDatePicker(); + initDatePicker(); this.initIssuableSelect(); } // eslint-disable-next-line class-methods-use-this - initMilestoneDatePicker() { - $('.datepicker').each(function initPikadayMilestone() { - const $datePicker = $(this); - const datePickerVal = $datePicker.val(); - - const calendar = new Pikaday({ - field: $datePicker.get(0), - theme: 'gitlab-theme animate-picker', - format: 'yyyy-mm-dd', - container: $datePicker.parent().get(0), - parse: (dateString) => parsePikadayDate(dateString), - toString: (date) => pikadayToString(date), - onSelect(dateText) { - $datePicker.val(calendar.toString(dateText)); - }, - firstDay: gon.first_day_of_week, - }); - - calendar.setDate(parsePikadayDate(datePickerVal)); - - $datePicker.data('pikaday', calendar); - }); - - $('.js-clear-due-date,.js-clear-start-date').on('click', (e) => { - e.preventDefault(); - const calendar = $(e.target).siblings('.datepicker').data('pikaday'); - calendar.setDate(null); - }); - } - // eslint-disable-next-line class-methods-use-this initIssuableSelect() { const $loading = $('.js-issuable-update .due_date') .find('.block-loading') diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js index 79beb3a4857..249888ede9b 100644 --- a/app/assets/javascripts/editor/editor_lite.js +++ b/app/assets/javascripts/editor/editor_lite.js @@ -1,10 +1,10 @@ import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor'; -import { uuids } from '~/diffs/utils/uuids'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; import languages from '~/ide/lib/languages'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; import { registerLanguages } from '~/ide/utils'; import { joinPaths } from '~/lib/utils/url_utility'; +import { uuids } from '~/lib/utils/uuids'; import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX, diff --git a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js b/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js index 3d4f08131c1..05a020bd958 100644 --- a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js +++ b/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js @@ -1,4 +1,5 @@ import { Range } from 'monaco-editor'; +import { waitForCSSLoaded } from '~/helpers/startup_css_helper'; import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE } from '../constants'; const hashRegexp = new RegExp('#?L', 'g'); @@ -23,11 +24,18 @@ export class EditorLiteExtension { if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) { EditorLiteExtension.setupLineLinking(instance); } + EditorLiteExtension.deferRerender(instance); } else if (Object.entries(options).length) { throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION); } } + static deferRerender(instance) { + waitForCSSLoaded(() => { + instance.layout(); + }); + } + static highlightLines(instance) { const { hash } = window.location; if (!hash) { diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index 71cabe80529..e08d294b8c5 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -79,6 +79,7 @@ export default { :toggle-class="toggleClass" :boundary="getBoundaryElement()" menu-class="dropdown-extended-height" + category="tertiary" no-flip right lazy diff --git a/app/assets/javascripts/ensure_data.js b/app/assets/javascripts/ensure_data.js index 5b4d1afc9d0..69c81c35bd4 100644 --- a/app/assets/javascripts/ensure_data.js +++ b/app/assets/javascripts/ensure_data.js @@ -3,8 +3,8 @@ import { GlEmptyState } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { __ } from '~/locale'; -const ERROR_FETCHING_DATA_HEADER = __('Could not get the data properly'); -const ERROR_FETCHING_DATA_DESCRIPTION = __( +export const ERROR_FETCHING_DATA_HEADER = __('Could not get the data properly'); +export const ERROR_FETCHING_DATA_DESCRIPTION = __( 'Please try and refresh the page. If the problem persists please contact support.', ); diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index cbce887f491..f82d3065ca5 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -188,15 +188,37 @@ export default { </div> <template v-else> - <div - is="environment-item" - v-for="(children, index) in model.children" - :key="`env-item-${i}-${index}`" - :model="children" - :can-read-environment="canReadEnvironment" - :table-data="tableData" - data-qa-selector="environment_item" - /> + <template v-for="(child, index) in model.children"> + <div + is="environment-item" + :key="`environment-row-${i}-${index}`" + :model="child" + :can-read-environment="canReadEnvironment" + :table-data="tableData" + data-qa-selector="environment_item" + /> + + <div + v-if="shouldRenderDeployBoard(child)" + :key="`deploy-board-row-${i}-${index}`" + class="js-deploy-board-row" + > + <div class="deploy-board-container"> + <deploy-board + :deploy-board-data="child.deployBoardData" + :is-loading="child.isLoadingDeployBoard" + :is-empty="child.isEmptyDeployBoard" + :logs-path="child.logs_path" + @changeCanaryWeight="changeCanaryWeight(child, $event)" + /> + </div> + </div> + <environment-alert + v-if="shouldRenderAlert(model)" + :key="`alert-row-${i}-${index}`" + :environment="child" + /> + </template> <div :key="`sub-div-${i}`"> <div class="text-center gl-mt-3"> diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index f7fdbb03f04..a67e44b3348 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -185,6 +185,8 @@ export default class EnvironmentsStore { updated.isChildren = true; + updated = setDeployBoard(env, updated); + return updated; }); diff --git a/app/assets/javascripts/environments/stores/helpers.js b/app/assets/javascripts/environments/stores/helpers.js index 89457da0614..3330edd8830 100644 --- a/app/assets/javascripts/environments/stores/helpers.js +++ b/app/assets/javascripts/environments/stores/helpers.js @@ -4,7 +4,7 @@ */ export const setDeployBoard = (oldEnvironmentState, environment) => { let parsedEnvironment = environment; - if (environment.size === 1 && environment.rollout_status) { + if (!environment.isFolder && environment.rollout_status) { parsedEnvironment = { ...environment, hasDeployBoard: true, diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index 971eb21ee3b..d188574e721 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -1,11 +1,17 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import ErrorTrackingForm from './error_tracking_form.vue'; import ProjectDropdown from './project_dropdown.vue'; export default { - components: { ProjectDropdown, ErrorTrackingForm, GlButton }, + components: { + ErrorTrackingForm, + GlButton, + GlFormCheckbox, + GlFormGroup, + ProjectDropdown, + }, props: { initialApiHost: { type: String, @@ -66,18 +72,18 @@ export default { <template> <div> - <div class="form-check form-group"> - <input + <gl-form-group + :label="s__('ErrorTracking|Enable error tracking')" + label-for="error-tracking-enabled" + > + <gl-form-checkbox id="error-tracking-enabled" :checked="enabled" - class="form-check-input" - type="checkbox" - @change="updateEnabled($event.target.checked)" - /> - <label class="form-check-label" for="error-tracking-enabled">{{ - s__('ErrorTracking|Active') - }}</label> - </div> + @change="updateEnabled($event)" + > + {{ s__('ErrorTracking|Active') }} + </gl-form-checkbox> + </gl-form-group> <error-tracking-form /> <div class="form-group"> <project-dropdown @@ -95,7 +101,7 @@ export default { <gl-button :disabled="settingsLoading" class="js-error-tracking-button" - variant="success" + variant="confirm" @click="handleSubmit" > {{ __('Save changes') }} diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue index 4df324b396c..da942dbd0ae 100644 --- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -70,7 +70,7 @@ export default { v-show="connectSuccessful" class="js-error-tracking-connect-success gl-ml-2 text-success align-middle" :aria-label="__('Projects Successfully Retrieved')" - name="check-circle" + name="check" /> </div> </div> diff --git a/app/assets/javascripts/experimentation/components/experiment.vue b/app/assets/javascripts/experimentation/components/gitlab_experiment.vue index 294dbf77991..294dbf77991 100644 --- a/app/assets/javascripts/experimentation/components/experiment.vue +++ b/app/assets/javascripts/experimentation/components/gitlab_experiment.vue diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js index 572907f226d..e572280a62c 100644 --- a/app/assets/javascripts/experimentation/utils.js +++ b/app/assets/javascripts/experimentation/utils.js @@ -1,11 +1,20 @@ // This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment import { get } from 'lodash'; -import { DEFAULT_VARIANT, CANDIDATE_VARIANT } from './constants'; +import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants'; export function getExperimentData(experimentName) { return get(window, ['gon', 'experiment', experimentName]); } +export function getExperimentContexts(...experimentNames) { + return experimentNames + .map((name) => { + const data = getExperimentData(name); + return data && { schema: TRACKING_CONTEXT_SCHEMA, data }; + }) + .filter((context) => context); +} + export function isExperimentVariant(experimentName, variantName) { return getExperimentData(experimentName)?.variant === variantName; } diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index 115d68de5c9..9aa1accb0f2 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -35,8 +35,9 @@ export default { inject: { newUserListPath: { default: '' }, newFeatureFlagPath: { default: '' }, - canUserConfigure: { required: true }, - featureFlagsLimitExceeded: { required: true }, + canUserConfigure: {}, + featureFlagsLimitExceeded: {}, + featureFlagsLimit: {}, }, data() { const scope = getParameterByName('scope') || SCOPES.FEATURE_FLAG_SCOPE; diff --git a/app/assets/javascripts/feature_flags/index.js b/app/assets/javascripts/feature_flags/index.js index a92805d5d85..d2371a2aa8b 100644 --- a/app/assets/javascripts/feature_flags/index.js +++ b/app/assets/javascripts/feature_flags/index.js @@ -24,6 +24,7 @@ export default () => { newFeatureFlagPath, newUserListPath, featureFlagsLimitExceeded, + featureFlagsLimit, } = el.dataset; return new Vue({ @@ -40,7 +41,8 @@ export default () => { canUserConfigure: canUserAdminFeatureFlag !== undefined, newFeatureFlagPath, newUserListPath, - featureFlagsLimitExceeded, + featureFlagsLimitExceeded: featureFlagsLimitExceeded !== undefined, + featureFlagsLimit, }, render(createElement) { return createElement(FeatureFlagsComponent); diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js index 0da8cd0ad83..f933338514a 100644 --- a/app/assets/javascripts/filtered_search/dropdown_operator.js +++ b/app/assets/javascripts/filtered_search/dropdown_operator.js @@ -50,7 +50,7 @@ export default class DropdownOperator extends FilteredSearchDropdown { ]; const dropdownToken = this.tokenKeys.searchByKey(dropdownName.toLowerCase()); - if (gon.features?.notIssuableQueries && !dropdownToken?.hideNotEqual) { + if (!dropdownToken?.hideNotEqual) { dropdownData.push({ tag: 'not-equal', type: 'string', diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 2bec39ff4d8..7a79f8f5bfc 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -9,6 +9,10 @@ const FLASH_TYPES = { WARNING: 'warning', }; +const getCloseEl = (flashEl) => { + return flashEl.querySelector('.js-close-icon'); +}; + const hideFlash = (flashEl, fadeTransition = true) => { if (fadeTransition) { Object.assign(flashEl.style, { @@ -56,9 +60,7 @@ const createFlashEl = (message, type) => ` `; const removeFlashClickListener = (flashEl, fadeTransition) => { - flashEl - .querySelector('.js-close-icon') - .addEventListener('click', () => hideFlash(flashEl, fadeTransition)); + getCloseEl(flashEl).addEventListener('click', () => hideFlash(flashEl, fadeTransition)); }; /* @@ -114,6 +116,10 @@ const createFlash = function createFlash({ if (captureError && error) Sentry.captureException(error); + flashContainer.close = () => { + getCloseEl(flashEl).click(); + }; + return flashContainer; }; diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue index 69f89aa3857..e103949b86a 100644 --- a/app/assets/javascripts/frequent_items/components/app.vue +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -1,7 +1,11 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import { mapState, mapActions, mapGetters } from 'vuex'; import AccessorUtilities from '~/lib/utils/accessor'; +import { + mapVuexModuleState, + mapVuexModuleActions, + mapVuexModuleGetters, +} from '~/lib/utils/vuex_module_mappers'; import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; import eventHub from '../event_hub'; import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils'; @@ -16,6 +20,7 @@ export default { GlLoadingIcon, }, mixins: [frequentItemsMixin], + inject: ['vuexModule'], props: { currentUserName: { type: String, @@ -27,8 +32,13 @@ export default { }, }, computed: { - ...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']), - ...mapGetters(['hasSearchQuery']), + ...mapVuexModuleState((vm) => vm.vuexModule, [ + 'searchQuery', + 'isLoadingItems', + 'isFetchFailed', + 'items', + ]), + ...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasSearchQuery']), translations() { return this.getTranslations(['loadingMessage', 'header']); }, @@ -56,7 +66,11 @@ export default { eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); }, methods: { - ...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']), + ...mapVuexModuleActions((vm) => vm.vuexModule, [ + 'setNamespace', + 'setStorageKey', + 'fetchFrequentItems', + ]), dropdownOpenHandler() { if (this.searchQuery === '' || isMobile()) { this.fetchFrequentItems(); @@ -100,15 +114,16 @@ export default { </script> <template> - <div> - <frequent-items-search-input :namespace="namespace" /> + <div class="gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch gl-h-full"> + <frequent-items-search-input :namespace="namespace" data-testid="frequent-items-search-input" /> <gl-loading-icon v-if="isLoadingItems" :label="translations.loadingMessage" size="lg" class="loading-animation prepend-top-20" + data-testid="loading" /> - <div v-if="!isLoadingItems && !hasSearchQuery" class="section-header"> + <div v-if="!isLoadingItems && !hasSearchQuery" class="section-header" data-testid="header"> {{ translations.header }} </div> <frequent-items-list diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue index 6feeb5f03ad..1da0b88c9e9 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue @@ -59,7 +59,11 @@ export default { <template> <div class="frequent-items-list-container"> <ul ref="frequentItemsList" class="list-unstyled"> - <li v-if="isListEmpty" :class="{ 'section-failure': isFetchFailed }" class="section-empty"> + <li + v-if="isListEmpty" + :class="{ 'section-failure': isFetchFailed }" + class="section-empty gl-mb-3" + > {{ listEmptyMessage }} </li> <frequent-items-list-item diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 6f17e6a5282..c2f77cc8bc4 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -1,8 +1,8 @@ <script> /* eslint-disable vue/require-default-prop, vue/no-v-html */ -import { mapState } from 'vuex'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; +import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers'; import Tracking from '~/tracking'; import Identicon from '~/vue_shared/components/identicon.vue'; @@ -13,6 +13,7 @@ export default { Identicon, }, mixins: [trackingMixin], + inject: ['vuexModule'], props: { matcher: { type: String, @@ -42,7 +43,7 @@ export default { }, }, computed: { - ...mapState(['dropdownType']), + ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']), truncatedNamespace() { return truncateNamespace(this.namespace); }, diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue index b0972246e70..fa14ee15cf3 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue @@ -1,7 +1,7 @@ <script> import { GlSearchBoxByType } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { mapActions, mapState } from 'vuex'; +import { mapVuexModuleActions, mapVuexModuleState } from '~/lib/utils/vuex_module_mappers'; import Tracking from '~/tracking'; import frequentItemsMixin from './frequent_items_mixin'; @@ -12,13 +12,14 @@ export default { GlSearchBoxByType, }, mixins: [frequentItemsMixin, trackingMixin], + inject: ['vuexModule'], data() { return { searchQuery: '', }; }, computed: { - ...mapState(['dropdownType']), + ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']), translations() { return this.getTranslations(['searchInputPlaceholder']); }, @@ -32,7 +33,7 @@ export default { }, 500), }, methods: { - ...mapActions(['setSearchQuery']), + ...mapVuexModuleActions((vm) => vm.vuexModule, ['setSearchQuery']), }, }; </script> diff --git a/app/assets/javascripts/frequent_items/constants.js b/app/assets/javascripts/frequent_items/constants.js index 9bc17f5ef4f..9e1dcf70aa5 100644 --- a/app/assets/javascripts/frequent_items/constants.js +++ b/app/assets/javascripts/frequent_items/constants.js @@ -36,3 +36,17 @@ export const TRANSLATION_KEYS = { searchInputPlaceholder: s__('GroupsDropdown|Search your groups'), }, }; + +export const FREQUENT_ITEMS_PROJECTS = { + namespace: 'projects', + key: 'project', + vuexModule: 'frequentProjects', +}; + +export const FREQUENT_ITEMS_GROUPS = { + namespace: 'groups', + key: 'group', + vuexModule: 'frequentGroups', +}; + +export const FREQUENT_ITEMS_DROPDOWNS = [FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS]; diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js index eb8a404e8a5..f1540ffac28 100644 --- a/app/assets/javascripts/frequent_items/index.js +++ b/app/assets/javascripts/frequent_items/index.js @@ -1,25 +1,20 @@ import $ from 'jquery'; import Vue from 'vue'; +import Vuex from 'vuex'; import { createStore } from '~/frequent_items/store'; +import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; import Translate from '~/vue_shared/translate'; +import { FREQUENT_ITEMS_DROPDOWNS } from './constants'; import eventHub from './event_hub'; +Vue.use(Vuex); Vue.use(Translate); -const frequentItemDropdowns = [ - { - namespace: 'projects', - key: 'project', - }, - { - namespace: 'groups', - key: 'group', - }, -]; - export default function initFrequentItemDropdowns() { - frequentItemDropdowns.forEach((dropdown) => { - const { namespace, key } = dropdown; + const store = createStore(); + + FREQUENT_ITEMS_DROPDOWNS.forEach((dropdown) => { + const { namespace, key, vuexModule } = dropdown; const el = document.getElementById(`js-${namespace}-dropdown`); const navEl = document.getElementById(`nav-${namespace}-dropdown`); @@ -29,9 +24,6 @@ export default function initFrequentItemDropdowns() { return; } - const dropdownType = namespace; - const store = createStore({ dropdownType }); - import('./components/app.vue') .then(({ default: FrequentItems }) => { // eslint-disable-next-line no-new @@ -55,13 +47,23 @@ export default function initFrequentItemDropdowns() { }; }, render(createElement) { - return createElement(FrequentItems, { - props: { - namespace, - currentUserName: this.currentUserName, - currentItem: this.currentItem, + return createElement( + VuexModuleProvider, + { + props: { + vuexModule, + }, }, - }); + [ + createElement(FrequentItems, { + props: { + namespace, + currentUserName: this.currentUserName, + currentItem: this.currentItem, + }, + }), + ], + ); }, }); }) diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js index 83176d69802..1faacff84e5 100644 --- a/app/assets/javascripts/frequent_items/store/index.js +++ b/app/assets/javascripts/frequent_items/store/index.js @@ -1,17 +1,28 @@ -import Vue from 'vue'; import Vuex from 'vuex'; +import { FREQUENT_ITEMS_DROPDOWNS } from '../constants'; import * as actions from './actions'; import * as getters from './getters'; import mutations from './mutations'; import state from './state'; -Vue.use(Vuex); +export const createFrequentItemsModule = (initState = {}) => ({ + namespaced: true, + actions, + getters, + mutations, + state: state(initState), +}); -export const createStore = (initState = {}) => { - return new Vuex.Store({ - actions, - getters, - mutations, - state: state(initState), - }); +export const createStoreOptions = () => ({ + modules: FREQUENT_ITEMS_DROPDOWNS.reduce( + (acc, { namespace, vuexModule }) => + Object.assign(acc, { + [vuexModule]: createFrequentItemsModule({ dropdownType: namespace }), + }), + {}, + ), +}); + +export const createStore = () => { + return new Vuex.Store(createStoreOptions()); }; diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 22f88b1caa7..470c785f7e4 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -238,10 +238,13 @@ class GfmAutoComplete { const MEMBER_COMMAND = { ASSIGN: '/assign', UNASSIGN: '/unassign', + ASSIGN_REVIEWER: '/assign_reviewer', + UNASSIGN_REVIEWER: '/unassign_reviewer', REASSIGN: '/reassign', CC: '/cc', }; let assignees = []; + let reviewers = []; let command = ''; // Team Members @@ -286,9 +289,11 @@ class GfmAutoComplete { return null; }); - // Cache assignees list for easier filtering later + // Cache assignees & reviewers list for easier filtering later assignees = SidebarMediator.singleton?.store?.assignees?.map(createMemberSearchString) || []; + reviewers = + SidebarMediator.singleton?.store?.reviewers?.map(createMemberSearchString) || []; const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers); return match && match.length ? match[1] : null; @@ -309,6 +314,12 @@ class GfmAutoComplete { } else if (command === MEMBER_COMMAND.UNASSIGN) { // Only include members which are assigned to Issuable currently return data.filter((member) => assignees.includes(member.search)); + } else if (command === MEMBER_COMMAND.ASSIGN_REVIEWER) { + // Only include members which are not assigned as a reviewer to Issuable currently + return data.filter((member) => !reviewers.includes(member.search)); + } else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) { + // Only include members which are not assigned as a reviewer to Issuable currently + return data.filter((member) => reviewers.includes(member.search)); } return data; @@ -823,10 +834,10 @@ GfmAutoComplete.Members = { const lowercaseQuery = query.toLowerCase(); const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members; - return sortBy(members, [ + return sortBy( + members.filter((member) => nameOrUsernameIncludes(member, lowercaseQuery)), (member) => (nameOrUsernameStartsWith(member, lowercaseQuery) ? -1 : 0), - (member) => (nameOrUsernameIncludes(member, lowercaseQuery) ? -1 : 0), - ]); + ); }, }; GfmAutoComplete.Labels = { diff --git a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql index 101633ef7a7..41e7ed98c78 100644 --- a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql @@ -9,6 +9,7 @@ fragment AlertListItem on AlertManagementAlert { iid state title + webUrl } assignees { nodes { diff --git a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql new file mode 100644 index 00000000000..6ed3be84cd8 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql @@ -0,0 +1,10 @@ +fragment TimelogFragment on Timelog { + timeSpent + user { + name + } + spentAt + note { + body + } +} diff --git a/app/assets/javascripts/graphql_shared/mutations/dismiss_user_callout.mutation.graphql b/app/assets/javascripts/graphql_shared/mutations/dismiss_user_callout.mutation.graphql new file mode 100644 index 00000000000..2b831bf1338 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/mutations/dismiss_user_callout.mutation.graphql @@ -0,0 +1,9 @@ +mutation dismissUserCallout($input: UserCalloutCreateInput!) { + userCalloutCreate(input: $input) { + errors + userCallout { + dismissedAt + featureName + } + } +} diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue index 747cea6a46e..402d9a07c53 100644 --- a/app/assets/javascripts/groups/components/invite_members_banner.vue +++ b/app/assets/javascripts/groups/components/invite_members_banner.vue @@ -1,5 +1,6 @@ <script> import { GlBanner } from '@gitlab/ui'; +import eventHub from '~/invite_members/event_hub'; import { parseBoolean, setCookie, getCookie } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; @@ -11,7 +12,7 @@ export default { GlBanner, }, mixins: [trackingMixin], - inject: ['svgPath', 'inviteMembersPath', 'isDismissedKey', 'trackLabel'], + inject: ['svgPath', 'isDismissedKey', 'trackLabel'], data() { return { isDismissed: parseBoolean(getCookie(this.isDismissedKey)), @@ -20,11 +21,6 @@ export default { }, }; }, - created() { - this.$nextTick(() => { - this.addTrackingAttributesToButton(); - }); - }, mounted() { this.trackOnShow(); }, @@ -39,15 +35,12 @@ export default { if (!this.isDismissed) this.track(this.$options.displayEvent); }); }, - addTrackingAttributesToButton() { - if (this.$refs.banner === undefined) return; - - const button = this.$refs.banner.$el.querySelector(`[href='${this.inviteMembersPath}']`); - - if (button) { - button.setAttribute('data-track-event', this.$options.buttonClickEvent); - button.setAttribute('data-track-label', this.trackLabel); - } + openModal() { + eventHub.$emit('openModal', { + inviteeType: 'members', + source: this.$options.openModalSource, + }); + this.track(this.$options.buttonClickEvent); }, }, i18n: { @@ -59,6 +52,7 @@ export default { }, displayEvent: 'invite_members_banner_displayed', buttonClickEvent: 'invite_members_banner_button_clicked', + openModalSource: 'invite_members_banner', dismissEvent: 'invite_members_banner_dismissed', }; </script> @@ -70,8 +64,8 @@ export default { :title="$options.i18n.title" :button-text="$options.i18n.button_text" :svg-path="svgPath" - :button-link="inviteMembersPath" @close="handleClose" + @primary="openModal" > <p>{{ $options.i18n.body }}</p> </gl-banner> diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 4fed7f555f6..c2ef6414716 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -45,7 +45,6 @@ function initStatusTriggers() { defaultEmoji, currentMessage, currentAvailability, - canSetUserAvailability, currentClearStatusAfter, } = setStatusModalWrapperEl.dataset; @@ -54,7 +53,6 @@ function initStatusTriggers() { defaultEmoji, currentMessage, currentAvailability, - canSetUserAvailability, currentClearStatusAfter, }; }, @@ -64,7 +62,6 @@ function initStatusTriggers() { defaultEmoji, currentMessage, currentAvailability, - canSetUserAvailability, currentClearStatusAfter, } = this; @@ -74,7 +71,6 @@ function initStatusTriggers() { defaultEmoji, currentMessage, currentAvailability, - canSetUserAvailability, currentClearStatusAfter, }, }); diff --git a/app/assets/javascripts/help/help.js b/app/assets/javascripts/help/help.js deleted file mode 100644 index f5333042bb8..00000000000 --- a/app/assets/javascripts/help/help.js +++ /dev/null @@ -1,11 +0,0 @@ -// We will render the icons list here - -import $ from 'jquery'; - -export default () => { - if ($('#user-content-gitlab-icons').length > 0) { - const $iconsHeader = $('#user-content-gitlab-icons'); - const $iconsList = $('<div id="iconsList">ICONS</div>'); - $($iconsList).insertAfter($iconsHeader.parent()); - } -}; diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue index 35e2f99cb6a..bdfcff3136b 100644 --- a/app/assets/javascripts/ide/components/branches/item.vue +++ b/app/assets/javascripts/ide/components/branches/item.vue @@ -34,7 +34,7 @@ export default { <template> <a :href="branchHref" class="btn-link d-flex align-items-center"> <span class="d-flex gl-mr-3 ide-search-list-current-icon"> - <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" use-deprecated-sizes /> + <gl-icon v-if="isActive" :size="16" name="mobile-issue-close" /> </span> <span> <strong> {{ item.name }} </strong> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index 273d8d972f7..fcc900bbc96 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -76,8 +76,9 @@ export default { :value="$options.commitToCurrentBranch" :disabled="!canPushToBranch" :title="$options.currentBranchPermissionsTooltip" + data-qa-selector="commit_to_current_branch_radio_container" > - <span class="ide-option-label" data-qa-selector="commit_to_current_branch_radio"> + <span class="ide-option-label"> <gl-sprintf :message="s__('IDE|Commit to %{branchName} branch')"> <template #branchName> <strong class="monospace">{{ currentBranchText }}</strong> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue index 039b4a54b26..870355e884e 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue @@ -64,6 +64,7 @@ export default { :disabled="disabled" type="radio" name="commit-action" + data-qa-selector="commit_type_radio" @change="updateCommitAction($event.target.value)" /> <span class="gl-ml-3"> diff --git a/app/assets/javascripts/ide/components/file_alert.vue b/app/assets/javascripts/ide/components/file_alert.vue new file mode 100644 index 00000000000..2a894596bf4 --- /dev/null +++ b/app/assets/javascripts/ide/components/file_alert.vue @@ -0,0 +1,26 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { getAlert } from '../lib/alerts'; + +export default { + components: { + GlAlert, + }, + props: { + alertKey: { + type: Symbol, + required: true, + }, + }, + computed: { + alert() { + return getAlert(this.alertKey); + }, + }, +}; +</script> +<template> + <gl-alert v-bind="alert.props" @dismiss="alert.dismiss($store)"> + <component :is="alert.message" /> + </gl-alert> +</template> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index b57dcd4276c..bf2af9ffd49 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,4 +1,5 @@ <script> +import { debounce } from 'lodash'; import { mapState, mapGetters, mapActions } from 'vuex'; import { EDITOR_TYPE_DIFF, @@ -34,11 +35,13 @@ import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; import { getFileEditorOrDefault } from '../stores/modules/editor/utils'; import { extractMarkdownImagesFromEntries } from '../stores/utils'; import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils'; +import FileAlert from './file_alert.vue'; import FileTemplatesBar from './file_templates/bar.vue'; export default { name: 'RepoEditor', components: { + FileAlert, ContentViewer, DiffViewer, FileTemplatesBar, @@ -57,6 +60,7 @@ export default { globalEditor: null, modelManager: new ModelManager(), isEditorLoading: true, + unwatchCiYaml: null, }; }, computed: { @@ -74,6 +78,7 @@ export default { 'currentProjectId', ]), ...mapGetters([ + 'getAlert', 'currentMergeRequest', 'getStagedFile', 'isEditModeActive', @@ -82,6 +87,9 @@ export default { 'getJsonSchemaForPath', ]), ...mapGetters('fileTemplates', ['showFileTemplatesBar']), + alertKey() { + return this.getAlert(this.file); + }, fileEditor() { return getFileEditorOrDefault(this.fileEditors, this.file.path); }, @@ -136,6 +144,16 @@ export default { }, }, watch: { + 'file.name': { + handler() { + this.stopWatchingCiYaml(); + + if (this.file.name === '.gitlab-ci.yml') { + this.startWatchingCiYaml(); + } + }, + immediate: true, + }, file(newVal, oldVal) { if (oldVal.pending) { this.removePendingTab(oldVal); @@ -216,6 +234,7 @@ export default { 'removePendingTab', 'triggerFilesChange', 'addTempImage', + 'detectGitlabCiFileAlerts', ]), ...mapActions('editor', ['updateFileEditor']), initEditor() { @@ -422,6 +441,18 @@ export default { this.updateFileEditor({ path: this.file.path, data }); }, + startWatchingCiYaml() { + this.unwatchCiYaml = this.$watch( + 'file.content', + debounce(this.detectGitlabCiFileAlerts, 500), + ); + }, + stopWatchingCiYaml() { + if (this.unwatchCiYaml) { + this.unwatchCiYaml(); + this.unwatchCiYaml = null; + } + }, }, viewerTypes, FILE_VIEW_MODE_EDITOR, @@ -439,9 +470,8 @@ export default { role="button" data-testid="edit-tab" @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })" + >{{ __('Edit') }}</a > - {{ __('Edit') }} - </a> </li> <li v-if="previewMode" :class="previewTabCSS"> <a @@ -454,7 +484,8 @@ export default { </li> </ul> </div> - <file-templates-bar v-if="showFileTemplatesBar(file.name)" /> + <file-alert v-if="alertKey" :alert-key="alertKey" /> + <file-templates-bar v-else-if="showFileTemplatesBar(file.name)" /> <div v-show="showEditor" ref="editor" diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 2ce5bf7e271..7109c45a3fe 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -56,11 +56,12 @@ export function initIde(el, options = {}) { webIDEHelpPagePath: el.dataset.webIdeHelpPagePath, forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null, }); - this.setInitialData({ + this.init({ clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled), renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode), editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME, codesandboxBundlerUrl: el.dataset.codesandboxBundlerUrl, + environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance), }); }, beforeDestroy() { @@ -68,7 +69,7 @@ export function initIde(el, options = {}) { this.$emit('destroy'); }, methods: { - ...mapActions(['setEmptyStateSvgs', 'setLinks', 'setInitialData']), + ...mapActions(['setEmptyStateSvgs', 'setLinks', 'init']), }, render(createElement) { return createElement(rootComponent); diff --git a/app/assets/javascripts/ide/lib/alerts/environments.vue b/app/assets/javascripts/ide/lib/alerts/environments.vue new file mode 100644 index 00000000000..ac9a3c3f82c --- /dev/null +++ b/app/assets/javascripts/ide/lib/alerts/environments.vue @@ -0,0 +1,32 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { __ } from '~/locale'; + +export default { + components: { GlSprintf, GlLink }, + message: __( + "No deployments detected. Use environments to control your software's continuous deployment. %{linkStart}Learn more about deployment jobs.%{linkEnd}", + ), + computed: { + helpLink() { + return helpPagePath('ci/environments/index.md'); + }, + }, +}; +</script> +<template> + <span> + <gl-sprintf :message="$options.message"> + <template #link="{ content }"> + <gl-link + :href="helpLink" + target="_blank" + data-track-action="click_link" + data-track-experiment="in_product_guidance_environments_webide" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </span> +</template> diff --git a/app/assets/javascripts/ide/lib/alerts/index.js b/app/assets/javascripts/ide/lib/alerts/index.js new file mode 100644 index 00000000000..c9db9779b1f --- /dev/null +++ b/app/assets/javascripts/ide/lib/alerts/index.js @@ -0,0 +1,20 @@ +import { leftSidebarViews } from '../../constants'; +import EnvironmentsMessage from './environments.vue'; + +const alerts = [ + { + key: Symbol('ALERT_ENVIRONMENT'), + show: (state, file) => + state.currentActivityView === leftSidebarViews.commit.name && + file.path === '.gitlab-ci.yml' && + state.environmentsGuidanceAlertDetected && + !state.environmentsGuidanceAlertDismissed, + props: { variant: 'tip' }, + dismiss: ({ dispatch }) => dispatch('dismissEnvironmentsGuidance'), + message: EnvironmentsMessage, + }, +]; + +export const findAlertKeyToShow = (...args) => alerts.find((x) => x.show(...args))?.key; + +export const getAlert = (key) => alerts.find((x) => x.key === key); diff --git a/app/assets/javascripts/ide/messages.js b/app/assets/javascripts/ide/messages.js index 189226ef835..fe8eba823a8 100644 --- a/app/assets/javascripts/ide/messages.js +++ b/app/assets/javascripts/ide/messages.js @@ -1,11 +1,11 @@ import { s__ } from '~/locale'; export const MSG_CANNOT_PUSH_CODE_SHOULD_FORK = s__( - 'WebIDE|You need permission to edit files directly in this project. Fork this project to make your changes and submit a merge request.', + 'WebIDE|You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.', ); export const MSG_CANNOT_PUSH_CODE_GO_TO_FORK = s__( - 'WebIDE|You need permission to edit files directly in this project. Go to your fork to make changes and submit a merge request.', + 'WebIDE|You can’t edit files directly in this project. Go to your fork and submit a merge request with your changes.', ); export const MSG_CANNOT_PUSH_CODE = s__( @@ -13,7 +13,7 @@ export const MSG_CANNOT_PUSH_CODE = s__( ); export const MSG_CANNOT_PUSH_UNSIGNED = s__( - 'WebIDE|This project does not accept unsigned commits. You will not be able to commit your changes through the Web IDE.', + 'WebIDE|This project does not accept unsigned commits. You can’t commit changes through the Web IDE.', ); export const MSG_CANNOT_PUSH_UNSIGNED_SHORT = s__( diff --git a/app/assets/javascripts/ide/services/gql.js b/app/assets/javascripts/ide/services/gql.js index 89dda187360..c8c1031c0f3 100644 --- a/app/assets/javascripts/ide/services/gql.js +++ b/app/assets/javascripts/ide/services/gql.js @@ -18,3 +18,4 @@ const getClient = memoize(() => ); export const query = (...args) => getClient().query(...args); +export const mutate = (...args) => getClient().mutate(...args); diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 0aa08323d13..6bd28cd4fb6 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -1,8 +1,10 @@ import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql'; import Api from '~/api'; +import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; -import { query } from './gql'; +import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql'; +import { query, mutate } from './gql'; const fetchApiProjectData = (projectPath) => Api.project(projectPath).then(({ data }) => data); @@ -101,4 +103,16 @@ export default { const url = `${gon.relative_url_root}/${projectPath}/usage_ping/web_ide_pipelines_count`; return axios.post(url); }, + getCiConfig(projectPath, content) { + return query({ + query: ciConfig, + variables: { projectPath, content }, + }).then(({ data }) => data.ciConfig); + }, + dismissUserCallout(name) { + return mutate({ + mutation: dismissUserCallout, + variables: { input: { featureName: name } }, + }).then(({ data }) => data); + }, }; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index bf94f9d31c8..062dc150805 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -17,7 +17,7 @@ import * as types from './mutation_types'; export const redirectToUrl = (self, url) => visitUrl(url); -export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); +export const init = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data); export const discardAllChanges = ({ state, commit, dispatch }) => { state.changedFiles.forEach((file) => dispatch('restoreOriginalFile', file.path)); @@ -316,3 +316,4 @@ export * from './actions/tree'; export * from './actions/file'; export * from './actions/project'; export * from './actions/merge_request'; +export * from './actions/alert'; diff --git a/app/assets/javascripts/ide/stores/actions/alert.js b/app/assets/javascripts/ide/stores/actions/alert.js new file mode 100644 index 00000000000..4c33dc19520 --- /dev/null +++ b/app/assets/javascripts/ide/stores/actions/alert.js @@ -0,0 +1,18 @@ +import service from '../../services'; +import { + DETECT_ENVIRONMENTS_GUIDANCE_ALERT, + DISMISS_ENVIRONMENTS_GUIDANCE_ALERT, +} from '../mutation_types'; + +export const detectGitlabCiFileAlerts = ({ dispatch }, content) => + dispatch('detectEnvironmentsGuidance', content); + +export const detectEnvironmentsGuidance = ({ commit, state }, content) => + service.getCiConfig(state.currentProjectId, content).then((data) => { + commit(DETECT_ENVIRONMENTS_GUIDANCE_ALERT, data?.stages); + }); + +export const dismissEnvironmentsGuidance = ({ commit }) => + service.dismissUserCallout('web_ide_ci_environments_guidance').then(() => { + commit(DISMISS_ENVIRONMENTS_GUIDANCE_ALERT); + }); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index e8b1a0ea494..3c02b1d1da7 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -262,3 +262,5 @@ export const getJsonSchemaForPath = (state, getters) => (path) => { fileMatch: [`*${path}`], }; }; + +export * from './getters/alert'; diff --git a/app/assets/javascripts/ide/stores/getters/alert.js b/app/assets/javascripts/ide/stores/getters/alert.js new file mode 100644 index 00000000000..714e2d89b4f --- /dev/null +++ b/app/assets/javascripts/ide/stores/getters/alert.js @@ -0,0 +1,3 @@ +import { findAlertKeyToShow } from '../../lib/alerts'; + +export const getAlert = (state) => (file) => findAlertKeyToShow(state, file); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 76ba8339703..77755b179ef 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -70,3 +70,8 @@ export const RENAME_ENTRY = 'RENAME_ENTRY'; export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY'; export const RESTORE_TREE = 'RESTORE_TREE'; + +// Alert mutation types + +export const DETECT_ENVIRONMENTS_GUIDANCE_ALERT = 'DETECT_ENVIRONMENTS_GUIDANCE_ALERT'; +export const DISMISS_ENVIRONMENTS_GUIDANCE_ALERT = 'DISMISS_ENVIRONMENTS_GUIDANCE_ALERT'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index 576f861a090..48648796e66 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import * as types from './mutation_types'; +import alertMutations from './mutations/alert'; import branchMutations from './mutations/branch'; import fileMutations from './mutations/file'; import mergeRequestMutation from './mutations/merge_request'; @@ -244,4 +245,5 @@ export default { ...fileMutations, ...treeMutations, ...branchMutations, + ...alertMutations, }; diff --git a/app/assets/javascripts/ide/stores/mutations/alert.js b/app/assets/javascripts/ide/stores/mutations/alert.js new file mode 100644 index 00000000000..bb2d33a836b --- /dev/null +++ b/app/assets/javascripts/ide/stores/mutations/alert.js @@ -0,0 +1,21 @@ +import { + DETECT_ENVIRONMENTS_GUIDANCE_ALERT, + DISMISS_ENVIRONMENTS_GUIDANCE_ALERT, +} from '../mutation_types'; + +export default { + [DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, stages) { + if (!stages) { + return; + } + const hasEnvironments = stages?.nodes?.some((stage) => + stage.groups.nodes.some((group) => group.jobs.nodes.some((job) => job.environment)), + ); + const hasParsedCi = Array.isArray(stages.nodes); + + state.environmentsGuidanceAlertDetected = !hasEnvironments && hasParsedCi; + }, + [DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state) { + state.environmentsGuidanceAlertDismissed = true; + }, +}; diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index c1a83bf0726..83551e87f09 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -30,4 +30,6 @@ export default () => ({ renderWhitespaceInCode: false, editorTheme: DEFAULT_THEME, codesandboxBundlerUrl: null, + environmentsGuidanceAlertDismissed: false, + environmentsGuidanceAlertDetected: false, }); diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index f337520b0db..3daa5eebcb6 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -1,5 +1,6 @@ <script> import { + GlButton, GlEmptyState, GlDropdown, GlDropdownItem, @@ -8,10 +9,13 @@ import { GlLoadingIcon, GlSearchBoxByClick, GlSprintf, + GlSafeHtmlDirective as SafeHtml, + GlTooltip, } from '@gitlab/ui'; -import { s__, __ } from '~/locale'; +import { s__, __, n__ } from '~/locale'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; -import importGroupMutation from '../graphql/mutations/import_group.mutation.graphql'; +import { STATUSES } from '../../constants'; +import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql'; import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql'; import setTargetNamespaceMutation from '../graphql/mutations/set_target_namespace.mutation.graphql'; import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; @@ -23,6 +27,7 @@ const DEFAULT_PAGE_SIZE = PAGE_SIZES[0]; export default { components: { + GlButton, GlEmptyState, GlDropdown, GlDropdownItem, @@ -31,9 +36,13 @@ export default { GlLoadingIcon, GlSearchBoxByClick, GlSprintf, + GlTooltip, ImportTableRow, PaginationLinks, }, + directives: { + SafeHtml, + }, props: { sourceUrl: { @@ -65,12 +74,28 @@ export default { }, computed: { + groups() { + return this.bulkImportSourceGroups?.nodes ?? []; + }, + + hasGroupsWithValidationError() { + return this.groups.some((g) => g.validation_errors.length); + }, + + availableGroupsForImport() { + return this.groups.filter((g) => g.progress.status === STATUSES.NONE); + }, + + isImportAllButtonDisabled() { + return this.hasGroupsWithValidationError || this.availableGroupsForImport.length === 0; + }, + humanizedTotal() { return this.paginationInfo.total >= 1000 ? __('1000+') : this.paginationInfo.total; }, hasGroups() { - return this.bulkImportSourceGroups?.nodes?.length > 0; + return this.groups.length > 0; }, hasEmptyFilter() { @@ -105,6 +130,10 @@ export default { }, methods: { + groupsCount(count) { + return n__('%d group', '%d groups', count); + }, + setPage(page) { this.page = page; }, @@ -123,24 +152,57 @@ export default { }); }, - importGroup(sourceGroupId) { + importGroups(sourceGroupIds) { this.$apollo.mutate({ - mutation: importGroupMutation, - variables: { sourceGroupId }, + mutation: importGroupsMutation, + variables: { sourceGroupIds }, }); }, + importAllGroups() { + this.importGroups(this.availableGroupsForImport.map((g) => g.id)); + }, + setPageSize(size) { this.perPage = size; }, }, + gitlabLogo: window.gon.gitlab_logo, PAGE_SIZES, }; </script> <template> <div> + <h1 + class="gl-my-0 gl-py-4 gl-font-size-h1 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" + > + <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" /> + {{ s__('BulkImport|Import groups from GitLab') }} + <div ref="importAllButtonWrapper" class="gl-ml-auto"> + <gl-button + v-if="!$apollo.loading && hasGroups" + :disabled="isImportAllButtonDisabled" + variant="confirm" + @click="importAllGroups" + > + <gl-sprintf :message="s__('BulkImport|Import %{groups}')"> + <template #groups> + {{ groupsCount(availableGroupsForImport.length) }} + </template> + </gl-sprintf> + </gl-button> + </div> + <gl-tooltip v-if="isImportAllButtonDisabled" :target="() => $refs.importAllButtonWrapper"> + <template v-if="hasGroupsWithValidationError"> + {{ s__('BulkImport|One or more groups has validation errors') }} + </template> + <template v-else> + {{ s__('BulkImport|No groups on this page are available for import') }} + </template> + </gl-tooltip> + </h1> <div class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" > @@ -153,7 +215,7 @@ export default { <strong>{{ paginationInfo.end }}</strong> </template> <template #total> - <strong>{{ n__('%d group', '%d groups', paginationInfo.total) }}</strong> + <strong>{{ groupsCount(paginationInfo.total) }}</strong> </template> <template #filter> <strong>{{ filter }}</strong> @@ -180,7 +242,7 @@ export default { :description="s__('Check your source instance permissions.')" /> <template v-else> - <table class="gl-w-full"> + <table class="gl-w-full" data-qa-selector="import_table"> <thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1"> <th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th> <th class="gl-py-4 import-jobs-to-col">{{ s__('BulkImport|To new group') }}</th> @@ -196,7 +258,7 @@ export default { :group-path-regex="groupPathRegex" @update-target-namespace="updateTargetNamespace(group.id, $event)" @update-new-name="updateNewName(group.id, $event)" - @import-group="importGroup(group.id)" + @import-group="importGroups([group.id])" /> </template> </tbody> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue index aed879e75fb..60cd5bb0a96 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue @@ -10,8 +10,11 @@ import { GlFormInput, } from '@gitlab/ui'; import { joinPaths } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; +import addValidationErrorMutation from '../graphql/mutations/add_validation_error.mutation.graphql'; +import removeValidationErrorMutation from '../graphql/mutations/remove_validation_error.mutation.graphql'; import groupQuery from '../graphql/queries/group.query.graphql'; const DEBOUNCE_INTERVAL = 300; @@ -52,6 +55,27 @@ export default { fullPath: this.fullPath, }; }, + update({ existingGroup }) { + const variables = { + field: 'new_name', + sourceGroupId: this.group.id, + }; + + if (!existingGroup) { + this.$apollo.mutate({ + mutation: removeValidationErrorMutation, + variables, + }); + } else { + this.$apollo.mutate({ + mutation: addValidationErrorMutation, + variables: { + ...variables, + message: s__('BulkImport|Name already exists.'), + }, + }); + } + }, skip() { return !this.isNameValid || this.isAlreadyImported; }, @@ -63,8 +87,12 @@ export default { return this.group.import_target; }, + invalidNameValidationMessage() { + return this.group.validation_errors.find(({ field }) => field === 'new_name')?.message; + }, + isInvalid() { - return Boolean(!this.isNameValid || this.existingGroup); + return Boolean(!this.isNameValid || this.invalidNameValidationMessage); }, isNameValid() { @@ -72,11 +100,11 @@ export default { }, isAlreadyImported() { - return this.group.status !== STATUSES.NONE; + return this.group.progress.status !== STATUSES.NONE; }, isFinished() { - return this.group.status === STATUSES.FINISHED; + return this.group.progress.status === STATUSES.FINISHED; }, fullPath() { @@ -91,7 +119,11 @@ export default { </script> <template> - <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid"> + <tr + class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid" + data-qa-selector="import_item" + :data-qa-source-group="group.full_path" + > <td class="gl-p-4"> <gl-link :href="group.web_url" @@ -122,6 +154,7 @@ export default { :disabled="isAlreadyImported" toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1" + data-qa-selector="target_namespace_selector_dropdown" > <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{ s__('BulkImport|No parent') @@ -134,6 +167,8 @@ export default { <gl-dropdown-item v-for="ns in availableNamespaces" :key="ns.full_path" + data-qa-selector="target_group_dropdown_item" + :data-qa-group-name="ns.full_path" @click="$emit('update-target-namespace', ns.full_path)" > {{ ns.full_path }} @@ -157,22 +192,23 @@ export default { <template v-if="!isNameValid"> {{ __('Please choose a group URL with no special characters.') }} </template> - <template v-else-if="existingGroup"> - {{ s__('BulkImport|Name already exists.') }} + <template v-else-if="invalidNameValidationMessage"> + {{ invalidNameValidationMessage }} </template> </p> </div> </div> </td> - <td class="gl-p-4 gl-white-space-nowrap"> - <import-status :status="group.status" /> + <td class="gl-p-4 gl-white-space-nowrap" data-qa-selector="import_status_indicator"> + <import-status :status="group.progress.status" class="gl-mt-2" /> </td> <td class="gl-p-4"> <gl-button v-if="!isAlreadyImported" :disabled="isInvalid" - variant="success" + variant="confirm" category="secondary" + data-qa-selector="import_group_button" @click="$emit('import-group')" >{{ __('Import') }}</gl-button > diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js index d444cc77aa7..2cde3781a6a 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js @@ -4,40 +4,83 @@ import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { STATUSES } from '../../constants'; +import bulkImportSourceGroupItemFragment from './fragments/bulk_import_source_group_item.fragment.graphql'; +import setImportProgressMutation from './mutations/set_import_progress.mutation.graphql'; +import updateImportStatusMutation from './mutations/update_import_status.mutation.graphql'; import availableNamespacesQuery from './queries/available_namespaces.query.graphql'; +import bulkImportSourceGroupQuery from './queries/bulk_import_source_group.query.graphql'; import { SourceGroupsManager } from './services/source_groups_manager'; import { StatusPoller } from './services/status_poller'; +import typeDefs from './typedefs.graphql'; export const clientTypenames = { BulkImportSourceGroupConnection: 'ClientBulkImportSourceGroupConnection', BulkImportSourceGroup: 'ClientBulkImportSourceGroup', AvailableNamespace: 'ClientAvailableNamespace', BulkImportPageInfo: 'ClientBulkImportPageInfo', + BulkImportTarget: 'ClientBulkImportTarget', + BulkImportProgress: 'ClientBulkImportProgress', + BulkImportValidationError: 'ClientBulkImportValidationError', }; -export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) { - let statusPoller; +function makeGroup(data) { + const result = { + __typename: clientTypenames.BulkImportSourceGroup, + ...data, + }; + const NESTED_OBJECT_FIELDS = { + import_target: clientTypenames.BulkImportTarget, + progress: clientTypenames.BulkImportProgress, + }; - let sourceGroupManager; - const getGroupsManager = (client) => { - if (!sourceGroupManager) { - sourceGroupManager = new GroupsManager({ client, sourceUrl }); + Object.entries(NESTED_OBJECT_FIELDS).forEach(([field, type]) => { + if (!data[field]) { + return; } - return sourceGroupManager; - }; + result[field] = { + __typename: type, + ...data[field], + }; + }); + + return result; +} + +const localProgressId = (id) => `not-started-${id}`; + +export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) { + const groupsManager = new GroupsManager({ + sourceUrl, + }); + + let statusPoller; return { Query: { + async bulkImportSourceGroup(_, { id }, { client, getCacheKey }) { + return client.readFragment({ + fragment: bulkImportSourceGroupItemFragment, + fragmentName: 'BulkImportSourceGroupItem', + id: getCacheKey({ + __typename: clientTypenames.BulkImportSourceGroup, + id, + }), + }); + }, + async bulkImportSourceGroups(_, vars, { client }) { if (!statusPoller) { statusPoller = new StatusPoller({ - groupManager: getGroupsManager(client), + updateImportStatus: ({ id, status_name: status }) => + client.mutate({ + mutation: updateImportStatusMutation, + variables: { id, status }, + }), pollPath: endpoints.jobs, }); statusPoller.startPolling(); } - const groupsManager = getGroupsManager(client); return Promise.all([ axios.get(endpoints.status, { params: { @@ -59,19 +102,21 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr return { __typename: clientTypenames.BulkImportSourceGroupConnection, nodes: data.importable_data.map((group) => { - const cachedImportState = groupsManager.getImportStateFromStorageByGroupId( - group.id, - ); + const { jobId, importState: cachedImportState } = + groupsManager.getImportStateFromStorageByGroupId(group.id) ?? {}; - return { - __typename: clientTypenames.BulkImportSourceGroup, + return makeGroup({ ...group, - status: cachedImportState?.status ?? STATUSES.NONE, + validation_errors: [], + progress: { + id: jobId ?? localProgressId(group.id), + status: cachedImportState?.status ?? STATUSES.NONE, + }, import_target: cachedImportState?.importTarget ?? { new_name: group.full_path, target_namespace: availableNamespaces[0]?.full_path ?? '', }, - }; + }); }), pageInfo: { __typename: clientTypenames.BulkImportPageInfo, @@ -91,46 +136,149 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr ), }, Mutation: { - setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) { - getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => { - // eslint-disable-next-line no-param-reassign - sourceGroup.import_target.target_namespace = targetNamespace; + setTargetNamespace: (_, { targetNamespace, sourceGroupId }) => + makeGroup({ + id: sourceGroupId, + import_target: { + target_namespace: targetNamespace, + }, + }), + + setNewName: (_, { newName, sourceGroupId }) => + makeGroup({ + id: sourceGroupId, + import_target: { + new_name: newName, + }, + }), + + async setImportProgress(_, { sourceGroupId, status, jobId }) { + if (jobId) { + groupsManager.updateImportProgress(jobId, status); + } + + return makeGroup({ + id: sourceGroupId, + progress: { + id: jobId ?? localProgressId(sourceGroupId), + status, + }, }); }, - setNewName(_, { newName, sourceGroupId }, { client }) { - getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => { - // eslint-disable-next-line no-param-reassign - sourceGroup.import_target.new_name = newName; + async updateImportStatus(_, { id, status }) { + groupsManager.updateImportProgress(id, status); + + return { + __typename: clientTypenames.BulkImportProgress, + id, + status, + }; + }, + + async addValidationError(_, { sourceGroupId, field, message }, { client }) { + const { + data: { + bulkImportSourceGroup: { validation_errors: validationErrors, ...group }, + }, + } = await client.query({ + query: bulkImportSourceGroupQuery, + variables: { id: sourceGroupId }, }); + + return { + ...group, + validation_errors: [ + ...validationErrors.filter(({ field: f }) => f !== field), + { + __typename: clientTypenames.BulkImportValidationError, + field, + message, + }, + ], + }; }, - async importGroup(_, { sourceGroupId }, { client }) { - const groupManager = getGroupsManager(client); - const group = groupManager.findById(sourceGroupId); - groupManager.setImportStatus(group, STATUSES.SCHEDULING); - try { - const response = await axios.post(endpoints.createBulkImport, { - bulk_import: [ - { - source_type: 'group_entity', - source_full_path: group.full_path, - destination_namespace: group.import_target.target_namespace, - destination_name: group.import_target.new_name, - }, - ], - }); - groupManager.startImport({ group, importId: response.data.id }); - } catch (e) { - const message = e?.response?.data?.error ?? s__('BulkImport|Importing the group failed'); - createFlash({ message }); - groupManager.setImportStatus(group, STATUSES.NONE); - throw e; - } + async removeValidationError(_, { sourceGroupId, field }, { client }) { + const { + data: { + bulkImportSourceGroup: { validation_errors: validationErrors, ...group }, + }, + } = await client.query({ + query: bulkImportSourceGroupQuery, + variables: { id: sourceGroupId }, + }); + + return { + ...group, + validation_errors: validationErrors.filter(({ field: f }) => f !== field), + }; + }, + + async importGroups(_, { sourceGroupIds }, { client }) { + const groups = await Promise.all( + sourceGroupIds.map((id) => + client + .query({ + query: bulkImportSourceGroupQuery, + variables: { id }, + }) + .then(({ data }) => data.bulkImportSourceGroup), + ), + ); + + const GROUPS_BEING_SCHEDULED = sourceGroupIds.map((sourceGroupId) => + makeGroup({ + id: sourceGroupId, + progress: { + id: localProgressId(sourceGroupId), + status: STATUSES.SCHEDULING, + }, + }), + ); + + const defaultErrorMessage = s__('BulkImport|Importing the group failed'); + axios + .post(endpoints.createBulkImport, { + bulk_import: groups.map((group) => ({ + source_type: 'group_entity', + source_full_path: group.full_path, + destination_namespace: group.import_target.target_namespace, + destination_name: group.import_target.new_name, + })), + }) + .then(({ data: { id: jobId } }) => { + groupsManager.createImportState(jobId, { + status: STATUSES.CREATED, + groups, + }); + + return { status: STATUSES.CREATED, jobId }; + }) + .catch((e) => { + const message = e?.response?.data?.error ?? defaultErrorMessage; + createFlash({ message }); + return { status: STATUSES.NONE }; + }) + .then((newStatus) => + sourceGroupIds.forEach((sourceGroupId) => + client.mutate({ + mutation: setImportProgressMutation, + variables: { sourceGroupId, ...newStatus }, + }), + ), + ) + .catch(() => createFlash({ message: defaultErrorMessage })); + + return GROUPS_BEING_SCHEDULED; }, }, }; } export const createApolloClient = ({ sourceUrl, endpoints }) => - createDefaultClient(createResolvers({ sourceUrl, endpoints }), { assumeImmutableResults: true }); + createDefaultClient( + createResolvers({ sourceUrl, endpoints }), + { assumeImmutableResults: true }, + typeDefs, + ); diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql index 50774e36599..47675cd1bd0 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql @@ -1,8 +1,19 @@ +#import "./bulk_import_source_group_progress.fragment.graphql" + fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup { id web_url full_path full_name - status - import_target + progress { + ...BulkImportSourceGroupProgress + } + import_target { + target_namespace + new_name + } + validation_errors { + field + message + } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql new file mode 100644 index 00000000000..2d60bf82d65 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql @@ -0,0 +1,4 @@ +fragment BulkImportSourceGroupProgress on ClientBulkImportProgress { + id + status +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql new file mode 100644 index 00000000000..d95c460c046 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql @@ -0,0 +1,9 @@ +mutation addValidationError($sourceGroupId: String!, $field: String!, $message: String!) { + addValidationError(sourceGroupId: $sourceGroupId, field: $field, message: $message) @client { + id + validation_errors { + field + message + } + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql deleted file mode 100644 index 412608d3faf..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql +++ /dev/null @@ -1,3 +0,0 @@ -mutation importGroup($sourceGroupId: String!) { - importGroup(sourceGroupId: $sourceGroupId) @client -} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql new file mode 100644 index 00000000000..d8e46329e38 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql @@ -0,0 +1,9 @@ +mutation importGroups($sourceGroupIds: [String!]!) { + importGroups(sourceGroupIds: $sourceGroupIds) @client { + id + progress { + id + status + } + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql new file mode 100644 index 00000000000..940bf4dfaac --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql @@ -0,0 +1,9 @@ +mutation removeValidationError($sourceGroupId: String!, $field: String!) { + removeValidationError(sourceGroupId: $sourceGroupId, field: $field) @client { + id + validation_errors { + field + message + } + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql new file mode 100644 index 00000000000..2ec1269932a --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql @@ -0,0 +1,9 @@ +mutation setImportProgress($status: String!, $sourceGroupId: String!, $jobId: String) { + setImportProgress(status: $status, sourceGroupId: $sourceGroupId, jobId: $jobId) @client { + id + progress { + id + status + } + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql index 2bc19891401..354bf2a5815 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql @@ -1,3 +1,8 @@ mutation setNewName($newName: String!, $sourceGroupId: String!) { - setNewName(newName: $newName, sourceGroupId: $sourceGroupId) @client + setNewName(newName: $newName, sourceGroupId: $sourceGroupId) @client { + id + import_target { + new_name + } + } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql index fc98a1652c1..a0ef407f135 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql @@ -1,3 +1,8 @@ mutation setTargetNamespace($targetNamespace: String!, $sourceGroupId: String!) { - setTargetNamespace(targetNamespace: $targetNamespace, sourceGroupId: $sourceGroupId) @client + setTargetNamespace(targetNamespace: $targetNamespace, sourceGroupId: $sourceGroupId) @client { + id + import_target { + target_namespace + } + } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql new file mode 100644 index 00000000000..8c0233b2939 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql @@ -0,0 +1,6 @@ +mutation updateImportStatus($status: String!, $id: String!) { + updateImportStatus(status: $status, id: $id) @client { + id + status + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql new file mode 100644 index 00000000000..0aff23af96d --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql @@ -0,0 +1,7 @@ +#import "../fragments/bulk_import_source_group_item.fragment.graphql" + +query bulkImportSourceGroup($id: ID!) { + bulkImportSourceGroup(id: $id) @client { + ...BulkImportSourceGroupItem + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js index 2c88d25358f..97dbdbf518a 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js @@ -1,26 +1,10 @@ -import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; -import produce from 'immer'; import { debounce, merge } from 'lodash'; -import { STATUSES } from '../../../constants'; -import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql'; - -function extractTypeConditionFromFragment(fragment) { - return fragment.definitions[0]?.typeCondition.name.value; -} - -function generateGroupId(id) { - return defaultDataIdFromObject({ - __typename: extractTypeConditionFromFragment(ImportSourceGroupFragment), - id, - }); -} export const KEY = 'gl-bulk-imports-import-state'; export const DEBOUNCE_INTERVAL = 200; export class SourceGroupsManager { - constructor({ client, sourceUrl, storage = window.localStorage }) { - this.client = client; + constructor({ sourceUrl, storage = window.localStorage }) { this.sourceUrl = sourceUrl; this.storage = storage; @@ -29,51 +13,58 @@ export class SourceGroupsManager { loadImportStatesFromStorage() { try { - return JSON.parse(this.storage.getItem(KEY)) ?? {}; + return Object.fromEntries( + Object.entries(JSON.parse(this.storage.getItem(KEY)) ?? {}).map(([jobId, config]) => { + // new format of storage + if (config.groups) { + return [jobId, config]; + } + + return [ + jobId, + { + status: config.status, + groups: [{ id: config.id, importTarget: config.importTarget }], + }, + ]; + }), + ); } catch { return {}; } } - findById(id) { - const cacheId = generateGroupId(id); - return this.client.readFragment({ fragment: ImportSourceGroupFragment, id: cacheId }); - } - - update(group, fn) { - this.client.writeFragment({ - fragment: ImportSourceGroupFragment, - id: generateGroupId(group.id), - data: produce(group, fn), - }); - } - - updateById(id, fn) { - const group = this.findById(id); - this.update(group, fn); - } - - saveImportState(importId, group) { + createImportState(importId, jobConfig) { this.importStates[this.getStorageKey(importId)] = { - id: group.id, - importTarget: group.import_target, - status: group.status, + status: jobConfig.status, + groups: jobConfig.groups.map((g) => ({ importTarget: g.import_target, id: g.id })), }; this.saveImportStatesToStorage(); } - getImportStateFromStorage(importId) { - return this.importStates[this.getStorageKey(importId)]; + updateImportProgress(importId, status) { + const currentState = this.importStates[this.getStorageKey(importId)]; + if (!currentState) { + return; + } + + currentState.status = status; + this.saveImportStatesToStorage(); } getImportStateFromStorageByGroupId(groupId) { const PREFIX = this.getStorageKey(''); - const [, importState] = + const [jobId, importState] = Object.entries(this.importStates).find( - ([key, group]) => key.startsWith(PREFIX) && group.id === groupId, + ([key, state]) => key.startsWith(PREFIX) && state.groups.some((g) => g.id === groupId), ) ?? []; - return importState; + if (!jobId) { + return null; + } + + const group = importState.groups.find((g) => g.id === groupId); + return { jobId, importState: { ...group, status: importState.status } }; } getStorageKey(importId) { @@ -91,34 +82,4 @@ export class SourceGroupsManager { // empty catch intentional: storage might be unavailable or full } }, DEBOUNCE_INTERVAL); - - startImport({ group, importId }) { - this.setImportStatus(group, STATUSES.CREATED); - this.saveImportState(importId, group); - } - - setImportStatus(group, status) { - this.update(group, (sourceGroup) => { - // eslint-disable-next-line no-param-reassign - sourceGroup.status = status; - }); - } - - setImportStatusByImportId(importId, status) { - const importState = this.getImportStateFromStorage(importId); - if (!importState) { - return; - } - - if (importState.status !== status) { - importState.status = status; - } - - const group = this.findById(importState.id); - if (group?.id) { - this.setImportStatus(group, status); - } - - this.saveImportStatesToStorage(); - } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js index b80a575afce..0297b3d3428 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js @@ -5,13 +5,15 @@ import Poll from '~/lib/utils/poll'; import { s__ } from '~/locale'; export class StatusPoller { - constructor({ groupManager, pollPath }) { + constructor({ updateImportStatus, pollPath }) { this.eTagPoll = new Poll({ resource: { fetchJobs: () => axios.get(pollPath), }, method: 'fetchJobs', - successCallback: ({ data }) => this.updateImportsStatuses(data), + successCallback: ({ data: statuses }) => { + statuses.forEach((status) => updateImportStatus(status)); + }, errorCallback: () => createFlash({ message: s__('BulkImport|Update of import statuses with realtime changes failed'), @@ -25,17 +27,9 @@ export class StatusPoller { this.eTagPoll.stop(); } }); - - this.groupManager = groupManager; } startPolling() { this.eTagPoll.makeRequest(); } - - async updateImportsStatuses(importStatuses) { - importStatuses.forEach(({ id, status_name: statusName }) => { - this.groupManager.setImportStatusByImportId(id, statusName); - }); - } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql new file mode 100644 index 00000000000..c830aaa75e6 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql @@ -0,0 +1,65 @@ +type ClientBulkImportAvailableNamespace { + id: ID! + full_path: String! +} + +type ClientBulkImportTarget { + target_namespace: String! + new_name: String! +} + +type ClientBulkImportSourceGroupConnection { + nodes: [ClientBulkImportSourceGroup!]! + pageInfo: ClientBulkImportPageInfo! +} + +type ClientBulkImportProgress { + id: ID + status: String! +} + +type ClientBulkImportValidationError { + field: String! + message: String! +} + +type ClientBulkImportSourceGroup { + id: ID! + web_url: String! + full_path: String! + full_name: String! + progress: ClientBulkImportProgress! + import_target: ClientBulkImportTarget! + validation_errors: [ClientBulkImportValidationError!]! +} + +type ClientBulkImportPageInfo { + page: Int! + perPage: Int! + total: Int! + totalPages: Int! +} + +extend type Query { + bulkImportSourceGroup(id: ID!): ClientBulkImportSourceGroup + bulkImportSourceGroups( + page: Int! + perPage: Int! + filter: String! + ): ClientBulkImportSourceGroupConnection! + availableNamespaces: [ClientBulkImportAvailableNamespace!]! +} + +extend type Mutation { + setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup! + setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup! + importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]! + setImportProgress(id: ID, status: String!): ClientBulkImportSourceGroup! + updateImportProgress(id: ID, status: String!): ClientBulkImportProgress + addValidationError( + sourceGroupId: ID! + field: String! + message: String! + ): ClientBulkImportSourceGroup! + removeValidationError(sourceGroupId: ID!, field: String!): ClientBulkImportSourceGroup! +} diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 5638dc064d1..af99341b11f 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -10,7 +10,6 @@ import { GlIcon, GlEmptyState, } from '@gitlab/ui'; -import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; @@ -49,6 +48,7 @@ export default { label: s__('IncidentManagement|Severity'), thClass: `${thClass} w-15p`, tdClass: `${tdClass} sortable-cell`, + actualSortKey: 'SEVERITY', sortable: true, thAttr: TH_SEVERITY_TEST_ID, }, @@ -63,6 +63,7 @@ export default { label: s__('IncidentManagement|Date created'), thClass: `${thClass} gl-w-eighth`, tdClass: `${tdClass} sortable-cell`, + actualSortKey: 'CREATED', sortable: true, thAttr: TH_CREATED_AT_TEST_ID, }, @@ -72,7 +73,7 @@ export default { thClass: `gl-text-right gl-w-eighth`, tdClass: `${tdClass} gl-text-right`, thAttr: TH_INCIDENT_SLA_TEST_ID, - sortKey: 'SLA_DUE_AT', + actualSortKey: 'SLA_DUE_AT', sortable: true, sortDirection: 'asc', }, @@ -87,6 +88,7 @@ export default { label: s__('IncidentManagement|Published'), thClass: `${thClass} w-15p`, tdClass: `${tdClass} sortable-cell`, + actualSortKey: 'PUBLISHED', sortable: true, thAttr: TH_PUBLISHED_TEST_ID, }, @@ -174,8 +176,7 @@ export default { redirecting: false, incidents: {}, incidentsCount: {}, - sort: 'created_desc', - sortBy: 'createdAt', + sort: 'CREATED_DESC', sortDesc: true, statusFilter: '', filteredByStatus: '', @@ -256,20 +257,17 @@ export default { this.redirecting = true; }, fetchSortedData({ sortBy, sortDesc }) { - let sortKey; - // In bootstrap-vue v2.17.0, sortKey becomes natively supported and we can eliminate this function const field = this.availableFields.find(({ key }) => key === sortBy); const sortingDirection = sortDesc ? 'DESC' : 'ASC'; - // Use `sortKey` if provided, otherwise fall back to existing algorithm - if (field?.sortKey) { - sortKey = field.sortKey; - } else { - sortKey = convertToSnakeCase(sortBy).replace(/_.*/, '').toUpperCase(); - } - this.pagination = initialPaginationState; - this.sort = `${sortKey}_${sortingDirection}`; + + // BootstapVue natively supports a `sortKey` parameter, but using it results in the sorting + // icons not being updated properly in the header. We decided to fallback on `actualSortKey` + // to bypass BootstrapVue's behavior until the bug is addressed upstream. + // Related discussion: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60926/diffs#note_568020482 + // Upstream issue: https://github.com/bootstrap-vue/bootstrap-vue/issues/6602 + this.sort = `${field.actualSortKey}_${sortingDirection}`; }, getSeverity(severity) { return INCIDENT_SEVERITY[severity]; @@ -334,14 +332,14 @@ export default { <gl-table :items="incidents.list || []" :fields="availableFields" - :show-empty="true" :busy="loading" stacked="md" :tbody-tr-class="tbodyTrClass" - :no-local-sorting="true" - :sort-direction="'desc'" + sort-direction="desc" :sort-desc.sync="sortDesc" - :sort-by.sync="sortBy" + sort-by="createdAt" + show-empty + no-local-sorting sort-icon-left fixed @row-clicked="navigateToIncidentDetails" diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js index 577d8ecb777..d479838b491 100644 --- a/app/assets/javascripts/incidents_settings/constants.js +++ b/app/assets/javascripts/incidents_settings/constants.js @@ -43,7 +43,9 @@ export const I18N_ALERT_SETTINGS_FORM = { label: __('Send a single email notification to Owners and Maintainers for new alerts.'), }, autoCloseIncidents: { - label: __('Automatically close incidents when the associated Prometheus alert resolves.'), + label: __( + 'Automatically close associated incident when a recovery alert notification resolves an alert', + ), }, }; diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js index 59038b3d9fb..17c73fdf1c3 100644 --- a/app/assets/javascripts/init_issuable_sidebar.js +++ b/app/assets/javascripts/init_issuable_sidebar.js @@ -1,7 +1,6 @@ /* eslint-disable no-new */ import { mountSidebarLabels, getSidebarOptions } from '~/sidebar/mount_sidebar'; -import DueDateSelectors from './due_date_select'; import IssuableContext from './issuable_context'; import LabelsSelect from './labels_select'; import MilestoneSelect from './milestone_select'; @@ -19,7 +18,6 @@ export default () => { }); new LabelsSelect(); new IssuableContext(sidebarOptions.currentUser); - new DueDateSelectors(); Sidebar.initialize(); mountSidebarLabels(); diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue index 1e33ceb7835..f7d7f4aa010 100644 --- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue +++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue @@ -37,7 +37,6 @@ export default { <input name="service[active]" type="hidden" :value="activated || false" /> <gl-form-checkbox v-model="activated" - name="service[active]" class="gl-display-block" :disabled="isInheriting" @change="onChange" diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index aea4a8b1c0b..9bc01cdd9fc 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -1,6 +1,5 @@ <script> import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; import JiraUpgradeCta from './jira_upgrade_cta.vue'; @@ -16,7 +15,6 @@ export default { JiraIssueCreationVulnerabilities: () => import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'), }, - mixins: [glFeatureFlagsMixin()], props: { showJiraIssuesIntegration: { type: Boolean, @@ -76,7 +74,7 @@ export default { return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated; }, showJiraVulnerabilitiesOptions() { - return this.showJiraVulnerabilitiesIntegration && this.glFeatures.jiraForVulnerabilities; + return this.showJiraVulnerabilitiesIntegration; }, showUltimateUpgrade() { return this.showJiraIssuesIntegration && !this.showJiraVulnerabilitiesIntegration; diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index b0f19e5b585..93d8bcc4c19 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -16,13 +16,13 @@ const commentDetailOptions = [ { value: 'standard', label: s__('Integrations|Standard'), - help: s__('Integrations|Includes commit title and branch'), + help: s__('Integrations|Includes commit title and branch.'), }, { value: 'all_details', label: s__('Integrations|All details'), help: s__( - 'Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs', + 'Integrations|Includes Standard, plus the entire commit message, commit hash, and issue IDs', ), }, ]; @@ -144,7 +144,7 @@ export default { label-for="service[trigger]" :description=" s__( - 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) is created.', + 'Integrations|When you mention a Jira issue in a commit or merge request, GitLab creates a remote link and comment (if enabled).', ) " > diff --git a/app/assets/javascripts/invite_member/components/invite_member_modal.vue b/app/assets/javascripts/invite_member/components/invite_member_modal.vue deleted file mode 100644 index ec77e49ae53..00000000000 --- a/app/assets/javascripts/invite_member/components/invite_member_modal.vue +++ /dev/null @@ -1,67 +0,0 @@ -<script> -import { GlModal, GlLink } from '@gitlab/ui'; -import { BV_SHOW_MODAL } from '~/lib/utils/constants'; -import { s__, __ } from '~/locale'; -import { OPEN_MODAL, MODAL_ID } from '../constants'; -import eventHub from '../event_hub'; - -export default { - cancelProps: { - text: __('Got it'), - attributes: [ - { - variant: 'info', - }, - ], - }, - modalId: MODAL_ID, - components: { - GlLink, - GlModal, - }, - props: { - membersPath: { - type: String, - required: false, - default: '', - }, - }, - i18n: { - modalTitle: s__("InviteMember|Oops, this feature isn't ready yet"), - bodyTopMessage: s__( - "InviteMember|We're working to allow everyone to invite new members, making it easier for teams to get started with GitLab", - ), - bodyMiddleMessage: s__( - 'InviteMember|Until then, ask an owner to invite new project members for you', - ), - linkText: s__('InviteMember|See who can invite members for you'), - }, - mounted() { - eventHub.$on(OPEN_MODAL, this.openModal); - }, - methods: { - openModal() { - this.$root.$emit(BV_SHOW_MODAL, MODAL_ID); - }, - }, -}; -</script> -<template> - <gl-modal :modal-id="$options.modalId" size="sm" :action-cancel="$options.cancelProps"> - <template #modal-title> - {{ $options.i18n.modalTitle }} - <gl-emoji - class="gl-vertical-align-baseline font-size-inherit gl-mr-1" - data-name="sweat_smile" - /> - </template> - <p>{{ $options.i18n.bodyTopMessage }}</p> - <p>{{ $options.i18n.bodyMiddleMessage }}</p> - <gl-link - :href="membersPath" - data-track-event="click_who_can_invite_link" - data-track-label="invite_members_message" - >{{ $options.i18n.linkText }}</gl-link - > - </gl-modal> -</template> diff --git a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue deleted file mode 100644 index ee89e0bbf71..00000000000 --- a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue +++ /dev/null @@ -1,43 +0,0 @@ -<script> -import { GlLink } from '@gitlab/ui'; -import { OPEN_MODAL } from '../constants'; -import eventHub from '../event_hub'; - -export default { - components: { - GlLink, - }, - props: { - displayText: { - type: String, - required: false, - default: '', - }, - event: { - type: String, - required: false, - default: '', - }, - label: { - type: String, - required: false, - default: '', - }, - }, - methods: { - openModal() { - eventHub.$emit(OPEN_MODAL); - }, - }, -}; -</script> - -<template> - <gl-link - data-is-link="true" - :data-track-event="event" - :data-track-label="label" - @click="openModal" - >{{ displayText }} - </gl-link> -</template> diff --git a/app/assets/javascripts/invite_member/constants.js b/app/assets/javascripts/invite_member/constants.js deleted file mode 100644 index fee6e7a260a..00000000000 --- a/app/assets/javascripts/invite_member/constants.js +++ /dev/null @@ -1,2 +0,0 @@ -export const OPEN_MODAL = 'openModal'; -export const MODAL_ID = 'invite-member-modal'; diff --git a/app/assets/javascripts/invite_member/event_hub.js b/app/assets/javascripts/invite_member/event_hub.js deleted file mode 100644 index e31806ad199..00000000000 --- a/app/assets/javascripts/invite_member/event_hub.js +++ /dev/null @@ -1,3 +0,0 @@ -import createEventHub from '~/helpers/event_hub_factory'; - -export default createEventHub(); diff --git a/app/assets/javascripts/invite_member/init_invite_member_modal.js b/app/assets/javascripts/invite_member/init_invite_member_modal.js deleted file mode 100644 index a50d31c9e7a..00000000000 --- a/app/assets/javascripts/invite_member/init_invite_member_modal.js +++ /dev/null @@ -1,27 +0,0 @@ -import { GlToast } from '@gitlab/ui'; -import Vue from 'vue'; -import { isInIssuePage, isInDesignPage } from '~/lib/utils/common_utils'; -import InviteMemberModal from './components/invite_member_modal.vue'; - -Vue.use(GlToast); - -const isAssigneesWidgetShown = - (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget; - -export default function initInviteMembersModal() { - const el = document.querySelector('.js-invite-member-modal'); - - if (!el || isAssigneesWidgetShown) { - return false; - } - - const { membersPath } = el.dataset; - - return new Vue({ - el, - render: (createElement) => - createElement(InviteMemberModal, { - props: { membersPath }, - }), - }); -} diff --git a/app/assets/javascripts/invite_member/init_invite_member_trigger.js b/app/assets/javascripts/invite_member/init_invite_member_trigger.js deleted file mode 100644 index eb765ae83b0..00000000000 --- a/app/assets/javascripts/invite_member/init_invite_member_trigger.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue'; -import InviteMemberTrigger from './components/invite_member_trigger.vue'; - -export default function initInviteMembersTrigger() { - const el = document.querySelector('.js-invite-member-trigger'); - - if (!el) { - return false; - } - - return new Vue({ - el, - render: (createElement) => - createElement(InviteMemberTrigger, { - props: { ...el.dataset }, - }), - }); -} diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue index 7bdd55ddda3..f17440a4a14 100644 --- a/app/assets/javascripts/issuable/components/csv_export_modal.vue +++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue @@ -21,13 +21,11 @@ export default { props: { exportCsvPath: { type: String, - required: false, - default: '', + required: true, }, issuableCount: { type: Number, - required: false, - default: 0, + required: true, }, modalId: { type: String, diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue new file mode 100644 index 00000000000..cb768f2bc5b --- /dev/null +++ b/app/assets/javascripts/issuable/components/status_box.vue @@ -0,0 +1,103 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import Vue from 'vue'; +import { fetchPolicies } from '~/lib/graphql'; +import { __ } from '~/locale'; + +export const statusBoxState = Vue.observable({ + state: '', + updateStatus: null, +}); + +const CLASSES = { + opened: 'status-box-open', + locked: 'status-box-open', + closed: 'status-box-mr-closed', + merged: 'status-box-mr-merged', +}; + +const STATUS = { + opened: [__('Open'), 'issue-open-m'], + locked: [__('Open'), 'issue-open-m'], + closed: [__('Closed'), 'issue-close'], + merged: [__('Merged'), 'git-merge'], +}; + +export default { + components: { + GlIcon, + }, + inject: { + query: { default: null }, + projectPath: { default: null }, + iid: { default: null }, + }, + props: { + initialState: { + type: String, + required: false, + default: null, + }, + issuableType: { + type: String, + required: false, + default: '', + }, + }, + data() { + if (this.initialState) { + statusBoxState.state = this.initialState; + } + + return statusBoxState; + }, + computed: { + statusBoxClass() { + return CLASSES[`${this.issuableType}_${this.state}`] || CLASSES[this.state]; + }, + statusHumanName() { + return (STATUS[`${this.issuableType}_${this.state}`] || STATUS[this.state])[0]; + }, + statusIconName() { + return (STATUS[`${this.issuableType}_${this.state}`] || STATUS[this.state])[1]; + }, + }, + created() { + if (!statusBoxState.updateStatus) { + statusBoxState.updateStatus = this.fetchState; + } + }, + beforeDestroy() { + if (statusBoxState.updateStatus && this.query) { + statusBoxState.updateStatus = null; + } + }, + methods: { + async fetchState() { + const { data } = await this.$apollo.query({ + query: this.query, + variables: { + projectPath: this.projectPath, + iid: this.iid, + }, + fetchPolicy: fetchPolicies.NO_CACHE, + }); + + statusBoxState.state = data?.workspace?.issuable?.state; + }, + }, +}; +</script> + +<template> + <div :class="statusBoxClass" class="issuable-status-box status-box"> + <gl-icon + :name="statusIconName" + class="gl-display-block gl-sm-display-none!" + data-testid="status-icon" + /> + <span class="gl-display-none gl-sm-display-block"> + {{ statusHumanName }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 153123a005f..9a1ab23e366 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -54,9 +54,9 @@ export default class IssuableForm { this.wipRegex = new RegExp( '^\\s*(' + // Line start, then any amount of leading whitespace 'draft\\s-\\s' + // Draft_-_ where "_" are *exactly* one whitespace - '|\\[(draft|wip)\\]\\s*' + // [Draft] or [WIP] and any following whitespace - '|(draft|wip):\\s*' + // Draft: or WIP: and any following whitespace - '|(draft|wip)\\s+' + // Draft_ or WIP_ where "_" is at least one whitespace + '|\\[draft\\]\\s*' + // [Draft] or [WIP] and any following whitespace + '|draft:\\s*' + // Draft: or WIP: and any following whitespace + '|draft\\s+' + // Draft_ or WIP_ where "_" is at least one whitespace '|\\(draft\\)\\s*' + // (Draft) and any following whitespace ')+' + // At least one repeated match of the preceding parenthetical '\\s*', // Any amount of trailing whitespace @@ -146,18 +146,12 @@ export default class IssuableForm { workInProgress() { return this.wipRegex.test(this.titleField.val()); } - titlePrefixContainsDraft() { - const prefix = this.titleField.val().match(this.wipRegex); - - return prefix && prefix[0].match(/draft/i); - } renderWipExplanation() { if (this.workInProgress()) { // These strings are not "translatable" (the code is hard-coded to look for them) - this.$wipExplanation.find('code')[0].textContent = this.titlePrefixContainsDraft() - ? 'Draft' /* eslint-disable-line @gitlab/require-i18n-strings */ - : 'WIP'; + this.$wipExplanation.find('code')[0].textContent = + 'Draft'; /* eslint-disable-line @gitlab/require-i18n-strings */ this.$wipExplanation.show(); return this.$noWipExplanation.hide(); } diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index 5d497369f5a..7635536c54f 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -3,7 +3,7 @@ import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlTooltipDirective } from '@gi import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import { getTimeago } from '~/lib/utils/datetime_utility'; +import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/datetime_utility'; import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; @@ -50,6 +50,10 @@ export default { }, }, computed: { + createdInPastDay() { + const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date()); + return createdSecondsAgo < SECONDS_IN_DAY; + }, author() { return this.issuable.author; }, @@ -152,7 +156,12 @@ export default { </script> <template> - <li :id="`issuable_${issuable.id}`" class="issue gl-px-5!" :data-labels="labelIdsString"> + <li + :id="`issuable_${issuable.id}`" + class="issue gl-px-5!" + :class="{ closed: issuable.closedAt, today: createdInPastDay }" + :data-labels="labelIdsString" + > <div class="issuable-info-container"> <div v-if="showCheckbox" class="issue-check"> <gl-form-checkbox @@ -160,7 +169,9 @@ export default { :checked="checked" :data-id="issuable.id" @input="$emit('checked-input', $event)" - /> + > + <span class="gl-sr-only">{{ issuable.title }}</span> + </gl-form-checkbox> </div> <div class="issuable-main-info"> <div data-testid="issuable-title" class="issue-title title"> diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue index 6b95c3a578e..45584205be0 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue @@ -274,44 +274,47 @@ export default { <gl-skeleton-loading /> </li> </ul> - <component - :is="issuablesWrapper" - v-if="!issuablesLoading && issuables.length" - class="content-list issuable-list issues-list" - :class="{ 'manual-ordering': isManualOrdering }" - v-bind="$options.vueDraggableAttributes" - @update="handleVueDraggableUpdate" - > - <issuable-item - v-for="issuable in issuables" - :key="issuableId(issuable)" - :class="{ 'gl-cursor-grab': isManualOrdering }" - :issuable-symbol="issuableSymbol" - :issuable="issuable" - :enable-label-permalinks="enableLabelPermalinks" - :label-filter-param="labelFilterParam" - :show-checkbox="showBulkEditSidebar" - :checked="issuableChecked(issuable)" - @checked-input="handleIssuableCheckedInput(issuable, $event)" + <template v-else> + <component + :is="issuablesWrapper" + v-if="issuables.length > 0" + class="content-list issuable-list issues-list" + :class="{ 'manual-ordering': isManualOrdering }" + v-bind="$options.vueDraggableAttributes" + @update="handleVueDraggableUpdate" > - <template #reference> - <slot name="reference" :issuable="issuable"></slot> - </template> - <template #author> - <slot name="author" :author="issuable.author"></slot> - </template> - <template #timeframe> - <slot name="timeframe" :issuable="issuable"></slot> - </template> - <template #status> - <slot name="status" :issuable="issuable"></slot> - </template> - <template #statistics> - <slot name="statistics" :issuable="issuable"></slot> - </template> - </issuable-item> - </component> - <slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot> + <issuable-item + v-for="issuable in issuables" + :key="issuableId(issuable)" + :class="{ 'gl-cursor-grab': isManualOrdering }" + :issuable-symbol="issuableSymbol" + :issuable="issuable" + :enable-label-permalinks="enableLabelPermalinks" + :label-filter-param="labelFilterParam" + :show-checkbox="showBulkEditSidebar" + :checked="issuableChecked(issuable)" + @checked-input="handleIssuableCheckedInput(issuable, $event)" + > + <template #reference> + <slot name="reference" :issuable="issuable"></slot> + </template> + <template #author> + <slot name="author" :author="issuable.author"></slot> + </template> + <template #timeframe> + <slot name="timeframe" :issuable="issuable"></slot> + </template> + <template #status> + <slot name="status" :issuable="issuable"></slot> + </template> + <template #statistics> + <slot name="statistics" :issuable="issuable"></slot> + </template> + </issuable-item> + </component> + <slot v-else name="empty-state"></slot> + </template> + <gl-pagination v-if="showPaginationControls" :per-page="defaultPageSize" diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue index 6bc621b52e6..dfe158ae2b0 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue @@ -48,12 +48,13 @@ export default { <template #title> <span :title="tab.titleTooltip">{{ tab.title }}</span> <gl-badge - v-if="isTabCountNumeric(tab)" + v-if="tabCounts && isTabCountNumeric(tab)" variant="neutral" size="sm" class="gl-tab-counter-badge" - >{{ tabCounts[tab.name] }}</gl-badge > + {{ tabCounts[tab.name] }} + </gl-badge> </template> </gl-tab> </gl-tabs> diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index d153ff21a35..01b4e81a11a 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -418,6 +418,7 @@ export default { <div v-if="canUpdate && showForm"> <form-component :form-state="formState" + :initial-description-text="initialDescriptionText" :can-destroy="canDestroy" :issuable-templates="issuableTemplates" :markdown-docs-path="markdownDocsPath" diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index 570bc7df3cf..14df87e486b 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -53,6 +53,7 @@ export default { </script> <template> + <!-- eslint-disable @gitlab/vue-no-data-toggle --> <div class="dropdown js-issuable-selector-wrap" data-issuable-type="issues"> <button ref="toggle" diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index 76ea489fb86..b37a911a669 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -1,4 +1,5 @@ <script> +import { GlAlert } from '@gitlab/ui'; import $ from 'jquery'; import Autosave from '~/autosave'; import eventHub from '../event_hub'; @@ -15,6 +16,7 @@ export default { descriptionField, descriptionTemplate, editActions, + GlAlert, }, props: { canDestroy: { @@ -69,6 +71,16 @@ export default { required: false, default: true, }, + initialDescriptionText: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + showOutdatedDescriptionWarning: false, + }; }, computed: { hasIssuableTemplates() { @@ -102,11 +114,17 @@ export default { }, } = this.$refs; - this.autosaveDescription = new Autosave($(textarea), [ - document.location.pathname, - document.location.search, - 'description', - ]); + this.autosaveDescription = new Autosave( + $(textarea), + [document.location.pathname, document.location.search, 'description'], + null, + this.formState.lock_version, + ); + + const savedLockVersion = this.autosaveDescription.getSavedLockVersion(); + + this.showOutdatedDescriptionWarning = + savedLockVersion && String(this.formState.lock_version) !== savedLockVersion; this.autosaveTitle = new Autosave($(input), [ document.location.pathname, @@ -118,6 +136,27 @@ export default { this.autosaveDescription.reset(); this.autosaveTitle.reset(); }, + keepAutosave() { + const { + description: { + $refs: { textarea }, + }, + } = this.$refs; + + textarea.focus(); + this.showOutdatedDescriptionWarning = false; + }, + discardAutosave() { + const { + description: { + $refs: { textarea }, + }, + } = this.$refs; + + textarea.value = this.initialDescriptionText; + textarea.focus(); + this.showOutdatedDescriptionWarning = false; + }, }, }; </script> @@ -125,6 +164,21 @@ export default { <template> <form> <locked-warning v-if="showLockedWarning" /> + <gl-alert + v-if="showOutdatedDescriptionWarning" + class="gl-mb-5" + variant="warning" + :primary-button-text="__('Keep')" + :secondary-button-text="__('Discard')" + :dismissible="false" + @primaryAction="keepAutosave" + @secondaryAction="discardAutosave" + >{{ + __( + 'The comment you are editing has been changed by another user. Would you like to keep your changes and overwrite the new description or discard your changes?', + ) + }}</gl-alert + > <div class="row"> <div v-if="hasIssuableTemplates" class="col-sm-4 col-lg-3"> <description-template diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue index 57c5107fcbb..93ba338a6b3 100644 --- a/app/assets/javascripts/issues_list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue @@ -1,56 +1,72 @@ <script> -import { GlButton, GlEmptyState, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { + GlButton, + GlEmptyState, + GlFilteredSearchToken, + GlIcon, + GlLink, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { toNumber } from 'lodash'; import createFlash from '~/flash'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; import { + API_PARAM, + apiSortParams, CREATED_DESC, + i18n, + MAX_LIST_SIZE, PAGE_SIZE, - RELATIVE_POSITION_ASC, - sortOptions, - sortParams, + PARAM_DUE_DATE, + PARAM_PAGE, + PARAM_SORT, + PARAM_STATE, + RELATIVE_POSITION_DESC, + UPDATED_DESC, + URL_PARAM, + urlSortParams, } from '~/issues_list/constants'; +import { + convertToParams, + convertToSearchQuery, + getDueDateValue, + getFilterTokens, + getSortKey, + getSortOptions, +} from '~/issues_list/utils'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils'; -import { __, s__ } from '~/locale'; +import { + DEFAULT_NONE_ANY, + OPERATOR_IS_ONLY, + TOKEN_TITLE_ASSIGNEE, + TOKEN_TITLE_AUTHOR, + TOKEN_TITLE_CONFIDENTIAL, + TOKEN_TITLE_EPIC, + TOKEN_TITLE_ITERATION, + TOKEN_TITLE_LABEL, + TOKEN_TITLE_MILESTONE, + TOKEN_TITLE_MY_REACTION, + TOKEN_TITLE_WEIGHT, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; +import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue'; +import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue'; +import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; +import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; +import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue'; import eventHub from '../eventhub'; import IssueCardTimeInfo from './issue_card_time_info.vue'; export default { - CREATED_DESC, + i18n, IssuableListTabs, - PAGE_SIZE, - sortOptions, - sortParams, - i18n: { - calendarLabel: __('Subscribe to calendar'), - jiraIntegrationMessage: s__( - 'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.', - ), - jiraIntegrationSecondaryMessage: s__('JiraService|This feature requires a Premium plan.'), - jiraIntegrationTitle: s__('JiraService|Using Jira for issue tracking?'), - newIssueLabel: __('New issue'), - noClosedIssuesTitle: __('There are no closed issues'), - noOpenIssuesDescription: __('To keep this project going, create a new issue'), - noOpenIssuesTitle: __('There are no open issues'), - noIssuesSignedInDescription: __( - 'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.', - ), - noIssuesSignedInTitle: __( - 'The Issue Tracker is the place to add things that need to be improved or solved in a project', - ), - noIssuesSignedOutButtonText: __('Register / Sign In'), - noIssuesSignedOutDescription: __( - 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', - ), - noIssuesSignedOutTitle: __('There are no issues to show'), - noSearchResultsDescription: __('To widen your search, change or remove filters above'), - noSearchResultsTitle: __('Sorry, your filter produced no results'), - reorderError: __('An error occurred while reordering issues.'), - rssLabel: __('Subscribe to RSS feed'), - }, components: { CsvImportExportButtons, GlButton, @@ -58,6 +74,7 @@ export default { GlIcon, GlLink, GlSprintf, + IssuableByEmail, IssuableList, IssueCardTimeInfo, BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'), @@ -66,6 +83,12 @@ export default { GlTooltip: GlTooltipDirective, }, inject: { + autocompleteAwardEmojisPath: { + default: '', + }, + autocompleteUsersPath: { + default: '', + }, calendarPath: { default: '', }, @@ -81,12 +104,24 @@ export default { exportCsvPath: { default: '', }, - fullPath: { + groupEpicsPath: { default: '', }, + hasBlockedIssuesFeature: { + default: false, + }, hasIssues: { default: false, }, + hasIssueWeightsFeature: { + default: false, + }, + hasMultipleIssueAssigneesFeature: { + default: false, + }, + initialEmail: { + default: '', + }, isSignedIn: { default: false, }, @@ -99,6 +134,18 @@ export default { newIssuePath: { default: '', }, + projectIterationsPath: { + default: '', + }, + projectLabelsPath: { + default: '', + }, + projectMilestonesPath: { + default: '', + }, + projectPath: { + default: '', + }, rssPath: { default: '', }, @@ -110,51 +157,143 @@ export default { }, }, data() { - const orderBy = getParameterByName('order_by'); - const sort = getParameterByName('sort'); - const sortKey = Object.keys(sortParams).find( - (key) => sortParams[key].order_by === orderBy && sortParams[key].sort === sort, - ); - - const search = getParameterByName('search') || ''; - const tokens = search.split(' ').map((searchWord) => ({ - type: 'filtered-search-term', - value: { - data: searchWord, - }, - })); + const state = getParameterByName(PARAM_STATE); + const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; return { + dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)), exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), - filters: sortParams[sortKey] || {}, - filterTokens: tokens, + filterTokens: getFilterTokens(window.location.search), isLoading: false, issues: [], - page: toNumber(getParameterByName('page')) || 1, + page: toNumber(getParameterByName(PARAM_PAGE)) || 1, showBulkEditSidebar: false, - sortKey: sortKey || CREATED_DESC, - state: getParameterByName('state') || IssuableStates.Opened, + sortKey: getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey, + state: state || IssuableStates.Opened, totalIssues: 0, }; }, computed: { + isBulkEditButtonDisabled() { + return this.showBulkEditSidebar || !this.issues.length; + }, isManualOrdering() { - return this.sortKey === RELATIVE_POSITION_ASC; + return this.sortKey === RELATIVE_POSITION_DESC; }, isOpenTab() { return this.state === IssuableStates.Opened; }, + apiFilterParams() { + return convertToParams(this.filterTokens, API_PARAM); + }, + urlFilterParams() { + return convertToParams(this.filterTokens, URL_PARAM); + }, searchQuery() { - return ( - this.filterTokens - .map((searchTerm) => searchTerm.value.data) - .filter((searchWord) => Boolean(searchWord)) - .join(' ') || undefined - ); + return convertToSearchQuery(this.filterTokens) || undefined; + }, + searchTokens() { + const tokens = [ + { + type: 'author_username', + title: TOKEN_TITLE_AUTHOR, + icon: 'pencil', + token: AuthorToken, + dataType: 'user', + unique: true, + defaultAuthors: [], + fetchAuthors: this.fetchUsers, + }, + { + type: 'assignee_username', + title: TOKEN_TITLE_ASSIGNEE, + icon: 'user', + token: AuthorToken, + dataType: 'user', + unique: !this.hasMultipleIssueAssigneesFeature, + defaultAuthors: DEFAULT_NONE_ANY, + fetchAuthors: this.fetchUsers, + }, + { + type: 'milestone', + title: TOKEN_TITLE_MILESTONE, + icon: 'clock', + token: MilestoneToken, + unique: true, + defaultMilestones: [], + fetchMilestones: this.fetchMilestones, + }, + { + type: 'labels', + title: TOKEN_TITLE_LABEL, + icon: 'labels', + token: LabelToken, + defaultLabels: [], + fetchLabels: this.fetchLabels, + }, + { + type: 'my_reaction_emoji', + title: TOKEN_TITLE_MY_REACTION, + icon: 'thumb-up', + token: EmojiToken, + unique: true, + operators: OPERATOR_IS_ONLY, + fetchEmojis: this.fetchEmojis, + }, + { + type: 'confidential', + title: TOKEN_TITLE_CONFIDENTIAL, + icon: 'eye-slash', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATOR_IS_ONLY, + options: [ + { icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes }, + { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo }, + ], + }, + ]; + + if (this.projectIterationsPath) { + tokens.push({ + type: 'iteration', + title: TOKEN_TITLE_ITERATION, + icon: 'iteration', + token: IterationToken, + unique: true, + fetchIterations: this.fetchIterations, + }); + } + + if (this.groupEpicsPath) { + tokens.push({ + type: 'epic_id', + title: TOKEN_TITLE_EPIC, + icon: 'epic', + token: EpicToken, + unique: true, + fetchEpics: this.fetchEpics, + }); + } + + if (this.hasIssueWeightsFeature) { + tokens.push({ + type: 'weight', + title: TOKEN_TITLE_WEIGHT, + icon: 'weight', + token: WeightToken, + unique: true, + }); + } + + return tokens; }, showPaginationControls() { return this.issues.length > 0; }, + sortOptions() { + return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature); + }, tabCounts() { return Object.values(IssuableStates).reduce( (acc, state) => ({ @@ -166,24 +305,65 @@ export default { }, urlParams() { return { + due_date: this.dueDateFilter, page: this.page, search: this.searchQuery, state: this.state, - ...this.filters, + ...urlSortParams[this.sortKey], + ...this.urlFilterParams, }; }, }, + created() { + this.cache = {}; + }, mounted() { - eventHub.$on('issuables:toggleBulkEdit', (showBulkEditSidebar) => { - this.showBulkEditSidebar = showBulkEditSidebar; - }); + eventHub.$on('issuables:toggleBulkEdit', this.toggleBulkEditSidebar); this.fetchIssues(); }, beforeDestroy() { - // eslint-disable-next-line @gitlab/no-global-event-off - eventHub.$off('issuables:toggleBulkEdit'); + eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar); }, methods: { + fetchWithCache(path, cacheName, searchKey, search, wrapData = false) { + if (this.cache[cacheName]) { + const data = search + ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey }) + : this.cache[cacheName].slice(0, MAX_LIST_SIZE); + return wrapData ? Promise.resolve({ data }) : Promise.resolve(data); + } + + return axios.get(path).then(({ data }) => { + this.cache[cacheName] = data; + const result = data.slice(0, MAX_LIST_SIZE); + return wrapData ? { data: result } : result; + }); + }, + fetchEmojis(search) { + return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search); + }, + async fetchEpics(search) { + const epics = await this.fetchWithCache(this.groupEpicsPath, 'epics'); + if (!search) { + return epics.slice(0, MAX_LIST_SIZE); + } + const number = Number(search); + return Number.isNaN(number) + ? fuzzaldrinPlus.filter(epics, search, { key: 'title' }) + : epics.filter((epic) => epic.id === number); + }, + fetchLabels(search) { + return this.fetchWithCache(this.projectLabelsPath, 'labels', 'title', search); + }, + fetchMilestones(search) { + return this.fetchWithCache(this.projectMilestonesPath, 'milestones', 'title', search, true); + }, + fetchIterations(search) { + return axios.get(this.projectIterationsPath, { params: { search } }); + }, + fetchUsers(search) { + return axios.get(this.autocompleteUsersPath, { params: { search } }); + }, fetchIssues() { if (!this.hasIssues) { return undefined; @@ -194,12 +374,14 @@ export default { return axios .get(this.endpoint, { params: { + due_date: this.dueDateFilter, page: this.page, - per_page: this.$options.PAGE_SIZE, + per_page: PAGE_SIZE, search: this.searchQuery, state: this.state, with_labels_details: true, - ...this.filters, + ...apiSortParams[this.sortKey], + ...this.apiFilterParams, }, }) .then(({ data, headers }) => { @@ -209,7 +391,7 @@ export default { this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }) .catch(() => { - createFlash({ message: __('An error occurred while loading issues') }); + createFlash({ message: this.$options.i18n.errorFetchingIssues }); }) .finally(() => { this.isLoading = false; @@ -218,6 +400,15 @@ export default { getExportCsvPathWithQuery() { return `${this.exportCsvPath}${window.location.search}`; }, + getStatus(issue) { + if (issue.closedAt && issue.movedToId) { + return this.$options.i18n.closedMoved; + } + if (issue.closedAt) { + return this.$options.i18n.closed; + } + return undefined; + }, handleUpdateLegacyBulkEdit() { // If "select all" checkbox was checked, wait for all checkboxes // to be checked before updating IssuableBulkUpdateSidebar class @@ -225,7 +416,18 @@ export default { eventHub.$emit('issuables:updateBulkEdit'); }); }, - handleBulkUpdateClick() { + async handleBulkUpdateClick() { + if (!this.hasInitBulkEdit) { + const initBulkUpdateSidebar = await import('~/issuable_init_bulk_update_sidebar'); + initBulkUpdateSidebar.default.init('issuable_'); + + const usersSelect = await import('~/users_select'); + const UsersSelect = usersSelect.default; + new UsersSelect(); // eslint-disable-line no-new + + this.hasInitBulkEdit = true; + } + eventHub.$emit('issuables:enableBulkEdit'); }, handleClickTab(state) { @@ -278,151 +480,161 @@ export default { }, handleSort(value) { this.sortKey = value; - this.filters = sortParams[value]; this.fetchIssues(); }, + toggleBulkEditSidebar(showBulkEditSidebar) { + this.showBulkEditSidebar = showBulkEditSidebar; + }, }, }; </script> <template> - <issuable-list - v-if="hasIssues" - :namespace="fullPath" - recent-searches-storage-key="issues" - :search-input-placeholder="__('Search or filter results…')" - :search-tokens="[]" - :initial-filter-value="filterTokens" - :sort-options="$options.sortOptions" - :initial-sort-by="sortKey" - :issuables="issues" - :tabs="$options.IssuableListTabs" - :current-tab="state" - :tab-counts="tabCounts" - :issuables-loading="isLoading" - :is-manual-ordering="isManualOrdering" - :show-bulk-edit-sidebar="showBulkEditSidebar" - :show-pagination-controls="showPaginationControls" - :total-items="totalIssues" - :current-page="page" - :previous-page="page - 1" - :next-page="page + 1" - :url-params="urlParams" - @click-tab="handleClickTab" - @filter="handleFilter" - @page-change="handlePageChange" - @reorder="handleReorder" - @sort="handleSort" - @update-legacy-bulk-edit="handleUpdateLegacyBulkEdit" - > - <template #nav-actions> - <gl-button - v-gl-tooltip - :href="rssPath" - icon="rss" - :title="$options.i18n.rssLabel" - :aria-label="$options.i18n.rssLabel" - /> - <gl-button - v-gl-tooltip - :href="calendarPath" - icon="calendar" - :title="$options.i18n.calendarLabel" - :aria-label="$options.i18n.calendarLabel" - /> - <csv-import-export-buttons - class="gl-mr-3" - :export-csv-path="exportCsvPathWithQuery" - :issuable-count="totalIssues" - /> - <gl-button - v-if="canBulkUpdate" - :disabled="showBulkEditSidebar" - @click="handleBulkUpdateClick" - > - {{ __('Edit issues') }} - </gl-button> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ $options.i18n.newIssueLabel }} - </gl-button> - </template> + <div v-if="hasIssues"> + <issuable-list + :namespace="projectPath" + recent-searches-storage-key="issues" + :search-input-placeholder="$options.i18n.searchPlaceholder" + :search-tokens="searchTokens" + :initial-filter-value="filterTokens" + :sort-options="sortOptions" + :initial-sort-by="sortKey" + :issuables="issues" + :tabs="$options.IssuableListTabs" + :current-tab="state" + :tab-counts="tabCounts" + :issuables-loading="isLoading" + :is-manual-ordering="isManualOrdering" + :show-bulk-edit-sidebar="showBulkEditSidebar" + :show-pagination-controls="showPaginationControls" + :total-items="totalIssues" + :current-page="page" + :previous-page="page - 1" + :next-page="page + 1" + :url-params="urlParams" + @click-tab="handleClickTab" + @filter="handleFilter" + @page-change="handlePageChange" + @reorder="handleReorder" + @sort="handleSort" + @update-legacy-bulk-edit="handleUpdateLegacyBulkEdit" + > + <template #nav-actions> + <gl-button + v-gl-tooltip + :href="rssPath" + icon="rss" + :title="$options.i18n.rssLabel" + :aria-label="$options.i18n.rssLabel" + /> + <gl-button + v-gl-tooltip + :href="calendarPath" + icon="calendar" + :title="$options.i18n.calendarLabel" + :aria-label="$options.i18n.calendarLabel" + /> + <csv-import-export-buttons + v-if="isSignedIn" + class="gl-mr-3" + :export-csv-path="exportCsvPathWithQuery" + :issuable-count="totalIssues" + /> + <gl-button + v-if="canBulkUpdate" + :disabled="isBulkEditButtonDisabled" + @click="handleBulkUpdateClick" + > + {{ $options.i18n.editIssues }} + </gl-button> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> - <template #timeframe="{ issuable = {} }"> - <issue-card-time-info :issue="issuable" /> - </template> + <template #timeframe="{ issuable = {} }"> + <issue-card-time-info :issue="issuable" /> + </template> - <template #statistics="{ issuable = {} }"> - <li - v-if="issuable.mergeRequestsCount" - v-gl-tooltip - class="gl-display-none gl-sm-display-block" - :title="__('Related merge requests')" - data-testid="issuable-mr" - > - <gl-icon name="merge-request" /> - {{ issuable.mergeRequestsCount }} - </li> - <li - v-if="issuable.upvotes" - v-gl-tooltip - class="gl-display-none gl-sm-display-block" - :title="__('Upvotes')" - data-testid="issuable-upvotes" - > - <gl-icon name="thumb-up" /> - {{ issuable.upvotes }} - </li> - <li - v-if="issuable.downvotes" - v-gl-tooltip - class="gl-display-none gl-sm-display-block" - :title="__('Downvotes')" - data-testid="issuable-downvotes" - > - <gl-icon name="thumb-down" /> - {{ issuable.downvotes }} - </li> - <blocking-issues-count - class="gl-display-none gl-sm-display-block" - :blocking-issues-count="issuable.blockingIssuesCount" - :is-list-item="true" - /> - </template> + <template #status="{ issuable = {} }"> + {{ getStatus(issuable) }} + </template> - <template #empty-state> - <gl-empty-state - v-if="searchQuery" - :description="$options.i18n.noSearchResultsDescription" - :title="$options.i18n.noSearchResultsTitle" - :svg-path="emptyStateSvgPath" - > - <template #actions> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ $options.i18n.newIssueLabel }} - </gl-button> - </template> - </gl-empty-state> + <template #statistics="{ issuable = {} }"> + <li + v-if="issuable.mergeRequestsCount" + v-gl-tooltip + class="gl-display-none gl-sm-display-block" + :title="$options.i18n.relatedMergeRequests" + data-testid="issuable-mr" + > + <gl-icon name="merge-request" /> + {{ issuable.mergeRequestsCount }} + </li> + <li + v-if="issuable.upvotes" + v-gl-tooltip + class="gl-display-none gl-sm-display-block" + :title="$options.i18n.upvotes" + data-testid="issuable-upvotes" + > + <gl-icon name="thumb-up" /> + {{ issuable.upvotes }} + </li> + <li + v-if="issuable.downvotes" + v-gl-tooltip + class="gl-display-none gl-sm-display-block" + :title="$options.i18n.downvotes" + data-testid="issuable-downvotes" + > + <gl-icon name="thumb-down" /> + {{ issuable.downvotes }} + </li> + <blocking-issues-count + class="gl-display-none gl-sm-display-block" + :blocking-issues-count="issuable.blockingIssuesCount" + :is-list-item="true" + /> + </template> - <gl-empty-state - v-else-if="isOpenTab" - :description="$options.i18n.noOpenIssuesDescription" - :title="$options.i18n.noOpenIssuesTitle" - :svg-path="emptyStateSvgPath" - > - <template #actions> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ $options.i18n.newIssueLabel }} - </gl-button> - </template> - </gl-empty-state> + <template #empty-state> + <gl-empty-state + v-if="searchQuery" + :description="$options.i18n.noSearchResultsDescription" + :title="$options.i18n.noSearchResultsTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + </gl-empty-state> - <gl-empty-state - v-else - :title="$options.i18n.noClosedIssuesTitle" - :svg-path="emptyStateSvgPath" - /> - </template> - </issuable-list> + <gl-empty-state + v-else-if="isOpenTab" + :description="$options.i18n.noOpenIssuesDescription" + :title="$options.i18n.noOpenIssuesTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + </gl-empty-state> + + <gl-empty-state + v-else + :title="$options.i18n.noClosedIssuesTitle" + :svg-path="emptyStateSvgPath" + /> + </template> + </issuable-list> + + <issuable-by-email v-if="initialEmail" class="gl-text-center gl-pt-5 gl-pb-7" /> + </div> <div v-else-if="isSignedIn"> <gl-empty-state diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index f6f23af80ba..54e9668d300 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -1,4 +1,11 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { + FILTER_ANY, + FILTER_CURRENT, + FILTER_NONE, + OPERATOR_IS, + OPERATOR_IS_NOT, +} from '~/vue_shared/components/filtered_search_bar/constants'; // Maps sort order as it appears in the URL query to API `order_by` and `sort` params. const PRIORITY = 'priority'; @@ -53,23 +60,78 @@ export const availableSortOptionsJira = [ }, ]; +export const i18n = { + calendarLabel: __('Subscribe to calendar'), + closed: __('CLOSED'), + closedMoved: __('CLOSED (MOVED)'), + confidentialNo: __('No'), + confidentialYes: __('Yes'), + downvotes: __('Downvotes'), + editIssues: __('Edit issues'), + errorFetchingIssues: __('An error occurred while loading issues'), + jiraIntegrationMessage: s__( + 'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.', + ), + jiraIntegrationSecondaryMessage: s__('JiraService|This feature requires a Premium plan.'), + jiraIntegrationTitle: s__('JiraService|Using Jira for issue tracking?'), + newIssueLabel: __('New issue'), + noClosedIssuesTitle: __('There are no closed issues'), + noOpenIssuesDescription: __('To keep this project going, create a new issue'), + noOpenIssuesTitle: __('There are no open issues'), + noIssuesSignedInDescription: __( + 'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.', + ), + noIssuesSignedInTitle: __( + 'The Issue Tracker is the place to add things that need to be improved or solved in a project', + ), + noIssuesSignedOutButtonText: __('Register / Sign In'), + noIssuesSignedOutDescription: __( + 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', + ), + noIssuesSignedOutTitle: __('There are no issues to show'), + noSearchResultsDescription: __('To widen your search, change or remove filters above'), + noSearchResultsTitle: __('Sorry, your filter produced no results'), + relatedMergeRequests: __('Related merge requests'), + reorderError: __('An error occurred while reordering issues.'), + rssLabel: __('Subscribe to RSS feed'), + searchPlaceholder: __('Search or filter results…'), + upvotes: __('Upvotes'), +}; + export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map'; -export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; +export const PARAM_DUE_DATE = 'due_date'; +export const PARAM_PAGE = 'page'; +export const PARAM_SORT = 'sort'; +export const PARAM_STATE = 'state'; + +export const DUE_DATE_NONE = '0'; +export const DUE_DATE_ANY = ''; +export const DUE_DATE_OVERDUE = 'overdue'; +export const DUE_DATE_WEEK = 'week'; +export const DUE_DATE_MONTH = 'month'; +export const DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS = 'next_month_and_previous_two_weeks'; +export const DUE_DATE_VALUES = [ + DUE_DATE_NONE, + DUE_DATE_ANY, + DUE_DATE_OVERDUE, + DUE_DATE_WEEK, + DUE_DATE_MONTH, + DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS, +]; + export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; export const CREATED_ASC = 'CREATED_ASC'; export const CREATED_DESC = 'CREATED_DESC'; export const DUE_DATE_ASC = 'DUE_DATE_ASC'; export const DUE_DATE_DESC = 'DUE_DATE_DESC'; -export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC'; export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC'; export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC'; export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC'; export const POPULARITY_ASC = 'POPULARITY_ASC'; export const POPULARITY_DESC = 'POPULARITY_DESC'; -export const PRIORITY_ASC = 'PRIORITY_ASC'; export const PRIORITY_DESC = 'PRIORITY_DESC'; -export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC'; +export const RELATIVE_POSITION_DESC = 'RELATIVE_POSITION_DESC'; export const UPDATED_ASC = 'UPDATED_ASC'; export const UPDATED_DESC = 'UPDATED_DESC'; export const WEIGHT_ASC = 'WEIGHT_ASC'; @@ -78,13 +140,19 @@ export const WEIGHT_DESC = 'WEIGHT_DESC'; const SORT_ASC = 'asc'; const SORT_DESC = 'desc'; +const CREATED_DATE_SORT = 'created_date'; +const CREATED_ASC_SORT = 'created_asc'; +const UPDATED_DESC_SORT = 'updated_desc'; +const UPDATED_ASC_SORT = 'updated_asc'; +const MILESTONE_SORT = 'milestone'; +const MILESTONE_DUE_DESC_SORT = 'milestone_due_desc'; +const DUE_DATE_DESC_SORT = 'due_date_desc'; +const POPULARITY_ASC_SORT = 'popularity_asc'; +const WEIGHT_DESC_SORT = 'weight_desc'; +const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc'; const BLOCKING_ISSUES = 'blocking_issues'; -export const sortParams = { - [PRIORITY_ASC]: { - order_by: PRIORITY, - sort: SORT_ASC, - }, +export const apiSortParams = { [PRIORITY_DESC]: { order_by: PRIORITY, sort: SORT_DESC, @@ -129,15 +197,11 @@ export const sortParams = { order_by: POPULARITY, sort: SORT_DESC, }, - [LABEL_PRIORITY_ASC]: { - order_by: LABEL_PRIORITY, - sort: SORT_ASC, - }, [LABEL_PRIORITY_DESC]: { order_by: LABEL_PRIORITY, sort: SORT_DESC, }, - [RELATIVE_POSITION_ASC]: { + [RELATIVE_POSITION_DESC]: { order_by: RELATIVE_POSITION, per_page: 100, sort: SORT_ASC, @@ -150,95 +214,233 @@ export const sortParams = { order_by: WEIGHT, sort: SORT_DESC, }, - [BLOCKING_ISSUES_ASC]: { - order_by: BLOCKING_ISSUES, - sort: SORT_ASC, - }, [BLOCKING_ISSUES_DESC]: { order_by: BLOCKING_ISSUES, sort: SORT_DESC, }, }; -export const sortOptions = [ - { - id: 1, - title: __('Priority'), - sortDirection: { - ascending: PRIORITY_ASC, - descending: PRIORITY_DESC, - }, +export const urlSortParams = { + [PRIORITY_DESC]: { + sort: PRIORITY, }, - { - id: 2, - title: __('Created date'), - sortDirection: { - ascending: CREATED_ASC, - descending: CREATED_DESC, + [CREATED_ASC]: { + sort: CREATED_ASC_SORT, + }, + [CREATED_DESC]: { + sort: CREATED_DATE_SORT, + }, + [UPDATED_ASC]: { + sort: UPDATED_ASC_SORT, + }, + [UPDATED_DESC]: { + sort: UPDATED_DESC_SORT, + }, + [MILESTONE_DUE_ASC]: { + sort: MILESTONE_SORT, + }, + [MILESTONE_DUE_DESC]: { + sort: MILESTONE_DUE_DESC_SORT, + }, + [DUE_DATE_ASC]: { + sort: DUE_DATE, + }, + [DUE_DATE_DESC]: { + sort: DUE_DATE_DESC_SORT, + }, + [POPULARITY_ASC]: { + sort: POPULARITY_ASC_SORT, + }, + [POPULARITY_DESC]: { + sort: POPULARITY, + }, + [LABEL_PRIORITY_DESC]: { + sort: LABEL_PRIORITY, + }, + [RELATIVE_POSITION_DESC]: { + sort: RELATIVE_POSITION, + per_page: 100, + }, + [WEIGHT_ASC]: { + sort: WEIGHT, + }, + [WEIGHT_DESC]: { + sort: WEIGHT_DESC_SORT, + }, + [BLOCKING_ISSUES_DESC]: { + sort: BLOCKING_ISSUES_DESC_SORT, + }, +}; + +export const MAX_LIST_SIZE = 10; + +export const API_PARAM = 'apiParam'; +export const URL_PARAM = 'urlParam'; +export const NORMAL_FILTER = 'normalFilter'; +export const SPECIAL_FILTER = 'specialFilter'; +export const ALTERNATIVE_FILTER = 'alternativeFilter'; +export const SPECIAL_FILTER_VALUES = [FILTER_NONE, FILTER_ANY, FILTER_CURRENT]; + +export const filters = { + author_username: { + [API_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'author_username', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[author_username]', + }, + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'author_username', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[author_username]', + }, }, }, - { - id: 3, - title: __('Last updated'), - sortDirection: { - ascending: UPDATED_ASC, - descending: UPDATED_DESC, + assignee_username: { + [API_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'assignee_username', + [SPECIAL_FILTER]: 'assignee_id', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[assignee_username]', + }, + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'assignee_username[]', + [SPECIAL_FILTER]: 'assignee_id', + [ALTERNATIVE_FILTER]: 'assignee_username', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[assignee_username][]', + }, }, }, - { - id: 4, - title: __('Milestone due date'), - sortDirection: { - ascending: MILESTONE_DUE_ASC, - descending: MILESTONE_DUE_DESC, + milestone: { + [API_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'milestone', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[milestone]', + }, + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'milestone_title', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[milestone_title]', + }, }, }, - { - id: 5, - title: __('Due date'), - sortDirection: { - ascending: DUE_DATE_ASC, - descending: DUE_DATE_DESC, + labels: { + [API_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'labels', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[labels]', + }, + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'label_name[]', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[label_name][]', + }, }, }, - { - id: 6, - title: __('Popularity'), - sortDirection: { - ascending: POPULARITY_ASC, - descending: POPULARITY_DESC, + my_reaction_emoji: { + [API_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'my_reaction_emoji', + [SPECIAL_FILTER]: 'my_reaction_emoji', + }, + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'my_reaction_emoji', + [SPECIAL_FILTER]: 'my_reaction_emoji', + }, }, }, - { - id: 7, - title: __('Label priority'), - sortDirection: { - ascending: LABEL_PRIORITY_ASC, - descending: LABEL_PRIORITY_DESC, + confidential: { + [API_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'confidential', + }, + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'confidential', + }, }, }, - { - id: 8, - title: __('Manual'), - sortDirection: { - ascending: RELATIVE_POSITION_ASC, - descending: RELATIVE_POSITION_ASC, + iteration: { + [API_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'iteration_title', + [SPECIAL_FILTER]: 'iteration_id', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[iteration_title]', + }, + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'iteration_title', + [SPECIAL_FILTER]: 'iteration_id', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[iteration_title]', + }, }, }, - { - id: 9, - title: __('Weight'), - sortDirection: { - ascending: WEIGHT_ASC, - descending: WEIGHT_DESC, + epic_id: { + [API_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'epic_id', + [SPECIAL_FILTER]: 'epic_id', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[epic_id]', + }, + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'epic_id', + [SPECIAL_FILTER]: 'epic_id', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[epic_id]', + }, }, }, - { - id: 10, - title: __('Blocking'), - sortDirection: { - ascending: BLOCKING_ISSUES_ASC, - descending: BLOCKING_ISSUES_DESC, + weight: { + [API_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'weight', + [SPECIAL_FILTER]: 'weight', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[weight]', + }, + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'weight', + [SPECIAL_FILTER]: 'weight', + }, + [OPERATOR_IS_NOT]: { + [NORMAL_FILTER]: 'not[weight]', + }, }, }, -]; +}; diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 0b64df50691..55719f6449b 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -1,12 +1,13 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import { IssuableType } from '~/issue_show/constants'; import IssuesListApp from '~/issues_list/components/issues_list_app.vue'; import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import IssuablesListApp from './components/issuables_list_app.vue'; import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue'; -function mountJiraIssuesListApp() { +export function mountJiraIssuesListApp() { const el = document.querySelector('.js-jira-issues-import-status'); if (!el) { @@ -36,7 +37,7 @@ function mountJiraIssuesListApp() { }); } -function mountIssuablesListApp() { +export function mountIssuablesListApp() { if (!gon.features?.vueIssuablesList) { return; } @@ -65,7 +66,7 @@ function mountIssuablesListApp() { }); } -export function initIssuesListApp() { +export function mountIssuesListApp() { const el = document.querySelector('.js-issues-list'); if (!el) { @@ -73,26 +74,38 @@ export function initIssuesListApp() { } const { + autocompleteAwardEmojisPath, + autocompleteUsersPath, calendarPath, canBulkUpdate, canEdit, canImportIssues, email, + emailsHelpPagePath, emptyStateSvgPath, endpoint, exportCsvPath, - fullPath, + groupEpicsPath, hasBlockedIssuesFeature, hasIssuableHealthStatusFeature, hasIssues, hasIssueWeightsFeature, + hasMultipleIssueAssigneesFeature, importCsvIssuesPath, + initialEmail, isSignedIn, issuesPath, jiraIntegrationPath, + markdownHelpPath, maxAttachmentSize, newIssuePath, projectImportJiraPath, + projectIterationsPath, + projectLabelsPath, + projectMilestonesPath, + projectPath, + quickActionsHelpPath, + resetPath, rssPath, showNewIssueLink, signInPath, @@ -104,19 +117,26 @@ export function initIssuesListApp() { // issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153 apolloProvider: {}, provide: { + autocompleteAwardEmojisPath, + autocompleteUsersPath, calendarPath, canBulkUpdate: parseBoolean(canBulkUpdate), emptyStateSvgPath, endpoint, - fullPath, + groupEpicsPath, hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), hasIssues: parseBoolean(hasIssues), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), + hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature), isSignedIn: parseBoolean(isSignedIn), issuesPath, jiraIntegrationPath, newIssuePath, + projectIterationsPath, + projectLabelsPath, + projectMilestonesPath, + projectPath, rssPath, showNewIssueLink: parseBoolean(showNewIssueLink), signInPath, @@ -130,12 +150,14 @@ export function initIssuesListApp() { showExportButton: parseBoolean(hasIssues), showImportButton: parseBoolean(canImportIssues), showLabel: !parseBoolean(hasIssues), + // For IssuableByEmail component + emailsHelpPagePath, + initialEmail, + issuableType: IssuableType.Issue, + markdownHelpPath, + quickActionsHelpPath, + resetPath, }, render: (createComponent) => createComponent(IssuesListApp), }); } - -export default function initIssuablesList() { - mountJiraIssuesListApp(); - mountIssuablesListApp(); -} diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues_list/utils.js new file mode 100644 index 00000000000..234fd59ca8d --- /dev/null +++ b/app/assets/javascripts/issues_list/utils.js @@ -0,0 +1,195 @@ +import { + BLOCKING_ISSUES_DESC, + CREATED_ASC, + CREATED_DESC, + DUE_DATE_ASC, + DUE_DATE_DESC, + DUE_DATE_VALUES, + filters, + LABEL_PRIORITY_DESC, + MILESTONE_DUE_ASC, + MILESTONE_DUE_DESC, + NORMAL_FILTER, + POPULARITY_ASC, + POPULARITY_DESC, + PRIORITY_DESC, + RELATIVE_POSITION_DESC, + SPECIAL_FILTER, + SPECIAL_FILTER_VALUES, + UPDATED_ASC, + UPDATED_DESC, + urlSortParams, + WEIGHT_ASC, + WEIGHT_DESC, +} from '~/issues_list/constants'; +import { isPositiveInteger } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; + +export const getSortKey = (sort) => + Object.keys(urlSortParams).find((key) => urlSortParams[key].sort === sort); + +export const getDueDateValue = (value) => (DUE_DATE_VALUES.includes(value) ? value : undefined); + +export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) => { + const sortOptions = [ + { + id: 1, + title: __('Priority'), + sortDirection: { + ascending: PRIORITY_DESC, + descending: PRIORITY_DESC, + }, + }, + { + id: 2, + title: __('Created date'), + sortDirection: { + ascending: CREATED_ASC, + descending: CREATED_DESC, + }, + }, + { + id: 3, + title: __('Last updated'), + sortDirection: { + ascending: UPDATED_ASC, + descending: UPDATED_DESC, + }, + }, + { + id: 4, + title: __('Milestone due date'), + sortDirection: { + ascending: MILESTONE_DUE_ASC, + descending: MILESTONE_DUE_DESC, + }, + }, + { + id: 5, + title: __('Due date'), + sortDirection: { + ascending: DUE_DATE_ASC, + descending: DUE_DATE_DESC, + }, + }, + { + id: 6, + title: __('Popularity'), + sortDirection: { + ascending: POPULARITY_ASC, + descending: POPULARITY_DESC, + }, + }, + { + id: 7, + title: __('Label priority'), + sortDirection: { + ascending: LABEL_PRIORITY_DESC, + descending: LABEL_PRIORITY_DESC, + }, + }, + { + id: 8, + title: __('Manual'), + sortDirection: { + ascending: RELATIVE_POSITION_DESC, + descending: RELATIVE_POSITION_DESC, + }, + }, + ]; + + if (hasIssueWeightsFeature) { + sortOptions.push({ + id: 9, + title: __('Weight'), + sortDirection: { + ascending: WEIGHT_ASC, + descending: WEIGHT_DESC, + }, + }); + } + + if (hasBlockedIssuesFeature) { + sortOptions.push({ + id: 10, + title: __('Blocking'), + sortDirection: { + ascending: BLOCKING_ISSUES_DESC, + descending: BLOCKING_ISSUES_DESC, + }, + }); + } + + return sortOptions; +}; + +const tokenTypes = Object.keys(filters); + +const getUrlParams = (tokenType) => + Object.values(filters[tokenType].urlParam).flatMap((filterObj) => Object.values(filterObj)); + +const urlParamKeys = tokenTypes.flatMap(getUrlParams); + +const getTokenTypeFromUrlParamKey = (urlParamKey) => + tokenTypes.find((tokenType) => getUrlParams(tokenType).includes(urlParamKey)); + +const getOperatorFromUrlParamKey = (tokenType, urlParamKey) => + Object.entries(filters[tokenType].urlParam).find(([, filterObj]) => + Object.values(filterObj).includes(urlParamKey), + )[0]; + +const convertToFilteredTokens = (locationSearch) => + Array.from(new URLSearchParams(locationSearch).entries()) + .filter(([key]) => urlParamKeys.includes(key)) + .map(([key, data]) => { + const type = getTokenTypeFromUrlParamKey(key); + const operator = getOperatorFromUrlParamKey(type, key); + return { + type, + value: { data, operator }, + }; + }); + +const convertToFilteredSearchTerms = (locationSearch) => + new URLSearchParams(locationSearch) + .get('search') + ?.split(' ') + .map((word) => ({ + type: FILTERED_SEARCH_TERM, + value: { + data: word, + }, + })) || []; + +export const getFilterTokens = (locationSearch) => { + if (!locationSearch) { + return []; + } + const filterTokens = convertToFilteredTokens(locationSearch); + const searchTokens = convertToFilteredSearchTerms(locationSearch); + return filterTokens.concat(searchTokens); +}; + +const getFilterType = (data, tokenType = '') => + SPECIAL_FILTER_VALUES.includes(data) || + (tokenType === 'assignee_username' && isPositiveInteger(data)) + ? SPECIAL_FILTER + : NORMAL_FILTER; + +export const convertToParams = (filterTokens, paramType) => + filterTokens + .filter((token) => token.type !== FILTERED_SEARCH_TERM) + .reduce((acc, token) => { + const filterType = getFilterType(token.value.data, token.type); + const param = filters[token.type][paramType][token.value.operator]?.[filterType]; + return Object.assign(acc, { + [param]: acc[param] ? [acc[param], token.value.data].flat() : token.value.data, + }); + }, {}); + +export const convertToSearchQuery = (filterTokens) => + filterTokens + .filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data) + .map((token) => token.value.data) + .join(' '); diff --git a/app/assets/javascripts/jira_connect/components/groups_list.vue b/app/assets/javascripts/jira_connect/components/groups_list.vue index 275ff820419..d764f778a9d 100644 --- a/app/assets/javascripts/jira_connect/components/groups_list.vue +++ b/app/assets/javascripts/jira_connect/components/groups_list.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui'; import { fetchGroups } from '~/jira_connect/api'; -import { defaultPerPage } from '~/jira_connect/constants'; +import { DEFAULT_GROUPS_PER_PAGE, MINIMUM_SEARCH_TERM_LENGTH } from '~/jira_connect/constants'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import GroupsListItem from './groups_list_item.vue'; @@ -25,24 +25,33 @@ export default { isLoadingInitial: true, isLoadingMore: false, page: 1, - perPage: defaultPerPage, totalItems: 0, errorMessage: null, + searchTerm: '', }; }, + computed: { + showPagination() { + return this.totalItems > this.$options.DEFAULT_GROUPS_PER_PAGE && this.groups.length > 0; + }, + }, mounted() { return this.loadGroups().finally(() => { this.isLoadingInitial = false; }); }, methods: { - loadGroups({ searchTerm } = {}) { - this.isLoadingMore = true; + loadGroups() { + // fetchGroups returns no results for search terms 0 < {length} < 3. + // The desired UX is to return the unfiltered results for searches {length} < 3. + // Here, we set the search to an empty string if {length} < 3 + const search = this.searchTerm?.length < MINIMUM_SEARCH_TERM_LENGTH ? '' : this.searchTerm; + this.isLoadingMore = true; return fetchGroups(this.groupsPath, { page: this.page, - perPage: this.perPage, - search: searchTerm, + perPage: this.$options.DEFAULT_GROUPS_PER_PAGE, + search, }) .then((response) => { const { page, total } = parseIntPagination(normalizeHeaders(response.headers)); @@ -58,9 +67,14 @@ export default { }); }, onGroupSearch(searchTerm) { - return this.loadGroups({ searchTerm }); + // keep a copy of the search term for pagination + this.searchTerm = searchTerm; + // reset the current page + this.page = 1; + return this.loadGroups(); }, }, + DEFAULT_GROUPS_PER_PAGE, }; </script> @@ -102,10 +116,10 @@ export default { <div class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-pagination - v-if="totalItems > perPage && groups.length > 0" + v-if="showPagination" v-model="page" class="gl-mb-0" - :per-page="perPage" + :per-page="$options.DEFAULT_GROUPS_PER_PAGE" :total-items="totalItems" @input="loadGroups" /> diff --git a/app/assets/javascripts/jira_connect/constants.js b/app/assets/javascripts/jira_connect/constants.js index 63b79581a1b..8dff83eabb5 100644 --- a/app/assets/javascripts/jira_connect/constants.js +++ b/app/assets/javascripts/jira_connect/constants.js @@ -1,2 +1,3 @@ -export const defaultPerPage = 10; +export const DEFAULT_GROUPS_PER_PAGE = 10; export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert'; +export const MINIMUM_SEARCH_TERM_LENGTH = 3; diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index 91ab68d5f39..be95001a396 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -3,6 +3,7 @@ import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml, GlAlert } from import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { throttle, isEmpty } from 'lodash'; import { mapGetters, mapState, mapActions } from 'vuex'; +import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.vue'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { sprintf } from '~/locale'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; @@ -32,6 +33,7 @@ export default { GlLoadingIcon, SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'), GlAlert, + CodeQualityWalkthrough, }, directives: { SafeHtml, @@ -72,6 +74,11 @@ export default { required: false, default: null, }, + codeQualityHelpUrl: { + type: String, + required: false, + default: null, + }, }, computed: { ...mapState([ @@ -120,6 +127,10 @@ export default { shouldRenderHeaderCallout() { return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure; }, + + shouldRenderCodeQualityWalkthrough() { + return this.job.status.group === 'failed-with-warnings'; + }, }, watch: { // Once the job log is loaded, @@ -190,7 +201,7 @@ export default { </script> <template> <div> - <gl-loading-icon v-if="isLoading" size="lg" class="qa-loading-animation prepend-top-20" /> + <gl-loading-icon v-if="isLoading" size="lg" class="qa-loading-animation gl-mt-6" /> <template v-else-if="shouldRenderContent"> <div class="build-page" data-testid="job-content"> @@ -216,6 +227,11 @@ export default { > <div v-safe-html="job.callout_message"></div> </gl-alert> + <code-quality-walkthrough + v-if="shouldRenderCodeQualityWalkthrough" + step="troubleshoot_job" + :link="codeQualityHelpUrl" + /> </header> <!-- EO Header Section --> @@ -256,17 +272,17 @@ export default { <div v-if="job.archived" - class="gl-mt-3 archived-job" - :class="{ 'sticky-top border-bottom-0': hasTrace }" + class="gl-mt-3 gl-py-2 gl-px-3 gl-align-items-center gl-z-index-1 gl-m-auto archived-job" + :class="{ 'sticky-top gl-border-bottom-0': hasTrace }" data-testid="archived-job" > - <gl-icon name="lock" class="align-text-bottom" /> + <gl-icon name="lock" class="gl-vertical-align-bottom" /> {{ __('This job is archived. Only the complete pipeline can be retried.') }} </div> <!-- job log --> <div v-if="hasTrace" - class="build-trace-container position-relative" + class="build-trace-container gl-relative" :class="{ 'gl-mt-3': !job.archived }" > <log-top-bar diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue index 00a570fe2f8..c08ac0317b8 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -35,33 +35,40 @@ export default { return text; }, + jobName() { + return this.job.name ? this.job.name : this.job.id; + }, + classes() { + return { + retried: this.job.retried, + 'gl-font-weight-bold': this.isActive, + }; + }, + dataTestId() { + return this.isActive ? 'active-job' : null; + }, }, }; </script> <template> - <div - class="build-job position-relative" - :class="{ - retried: job.retried, - active: isActive, - }" - > + <div class="build-job gl-relative" :class="classes"> <gl-link v-gl-tooltip:tooltip-container.left :href="job.status.details_path" :title="tooltipText" - class="js-job-link gl-display-flex gl-align-items-center" + class="gl-display-flex gl-align-items-center" + :data-testid="dataTestId" > <gl-icon v-if="isActive" name="arrow-right" - class="js-arrow-right icon-arrow-right position-absolute d-block" + class="icon-arrow-right gl-absolute gl-display-block" /> <ci-icon :status="job.status" /> - <span class="text-truncate w-100">{{ job.name ? job.name : job.id }}</span> + <span class="gl-text-truncate gl-w-full">{{ jobName }}</span> <gl-icon v-if="job.retried" name="retry" /> </gl-link> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index ea50a11bed6..957e8243f33 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -69,7 +69,10 @@ export default { <template> <div class="top-bar"> <!-- truncate information --> - <div class="truncated-info d-none d-sm-block float-left" data-testid="log-truncated-info"> + <div + class="truncated-info gl-display-none gl-sm-display-block gl-float-left" + data-testid="log-truncated-info" + > <template v-if="isTraceSizeVisible"> {{ jobLogSize }} <gl-link @@ -83,7 +86,7 @@ export default { </div> <!-- eo truncate information --> - <div class="controllers float-right"> + <div class="controllers gl-float-right"> <!-- links --> <gl-button v-if="rawPath" diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue new file mode 100644 index 00000000000..376482b0319 --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue @@ -0,0 +1,14 @@ +<script> +export default { + props: { + job: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div></div> +</template> diff --git a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue new file mode 100644 index 00000000000..ba5732d3d43 --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue @@ -0,0 +1,49 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + iconSize: 12, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlIcon, + }, + mixins: [timeagoMixin], + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + finishedTime() { + return this.job?.finishedAt; + }, + duration() { + return this.job?.duration; + }, + }, +}; +</script> + +<template> + <div> + <div v-if="duration" data-testid="job-duration"> + <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" /> + {{ durationTimeFormatted(duration) }} + </div> + <div v-if="finishedTime" data-testid="job-finished-time"> + <gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" /> + <time + v-gl-tooltip + :title="tooltipTitle(finishedTime)" + data-placement="top" + data-container="body" + > + {{ timeFormatted(finishedTime) }} + </time> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue new file mode 100644 index 00000000000..88a9f73258f --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue @@ -0,0 +1,163 @@ +<script> +import { GlBadge, GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { s__ } from '~/locale'; +import { SUCCESS_STATUS } from '../../../constants'; + +export default { + iconSize: 12, + badgeSize: 'sm', + i18n: { + stuckText: s__('Jobs|Job is stuck. Check runners.'), + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlBadge, + GlIcon, + GlLink, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + jobId() { + const id = getIdFromGraphQLId(this.job.id); + return `#${id}`; + }, + jobPath() { + return this.job.detailedStatus?.detailsPath; + }, + jobRef() { + return this.job?.refName; + }, + jobRefPath() { + return this.job?.refPath; + }, + jobTags() { + return this.job.tags; + }, + createdByTag() { + return this.job.createdByTag; + }, + triggered() { + return this.job.triggered; + }, + isManualJob() { + return this.job.manualJob; + }, + successfulJob() { + return this.job.status === SUCCESS_STATUS; + }, + showAllowedToFailBadge() { + return this.job.allowFailure && !this.successfulJob; + }, + isScheduledJob() { + return Boolean(this.job.scheduledAt); + }, + canReadJob() { + return this.job?.userPermissions?.readBuild; + }, + jobStuck() { + return this.job?.stuck; + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-text-truncate"> + <gl-link + v-if="canReadJob" + class="gl-text-gray-500!" + :href="jobPath" + data-testid="job-id-link" + > + {{ jobId }} + </gl-link> + + <span v-else data-testid="job-id-limited-access">{{ jobId }}</span> + + <gl-icon + v-if="jobStuck" + v-gl-tooltip="$options.i18n.stuckText" + name="warning" + :size="$options.iconSize" + data-testid="stuck-icon" + /> + + <div + class="gl-display-flex gl-align-items-center gl-lg-justify-content-start gl-justify-content-end" + > + <div v-if="jobRef" class="gl-max-w-15 gl-text-truncate"> + <gl-icon + v-if="createdByTag" + name="label" + :size="$options.iconSize" + data-testid="label-icon" + /> + <gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" /> + <gl-link + class="gl-font-weight-bold gl-text-gray-500!" + :href="job.refPath" + data-testid="job-ref" + >{{ job.refName }}</gl-link + > + </div> + + <span v-else>{{ __('none') }}</span> + + <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" /> + + <gl-link :href="job.commitPath" data-testid="job-sha">{{ job.shortSha }}</gl-link> + </div> + </div> + + <div> + <gl-badge + v-for="tag in jobTags" + :key="tag" + variant="info" + :size="$options.badgeSize" + data-testid="job-tag-badge" + > + {{ tag }} + </gl-badge> + + <gl-badge + v-if="triggered" + variant="info" + :size="$options.badgeSize" + data-testid="triggered-job-badge" + >{{ s__('Job|triggered') }} + </gl-badge> + <gl-badge + v-if="showAllowedToFailBadge" + variant="warning" + :size="$options.badgeSize" + data-testid="fail-job-badge" + >{{ s__('Job|allowed to fail') }} + </gl-badge> + <gl-badge + v-if="isScheduledJob" + variant="info" + :size="$options.badgeSize" + data-testid="delayed-job-badge" + >{{ s__('Job|delayed') }} + </gl-badge> + <gl-badge + v-if="isManualJob" + variant="info" + :size="$options.badgeSize" + data-testid="manual-job-badge" + > + {{ s__('Job|manual') }} + </gl-badge> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue b/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue new file mode 100644 index 00000000000..71f9397f5f5 --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue @@ -0,0 +1,50 @@ +<script> +import { GlAvatar, GlLink } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +export default { + components: { + GlAvatar, + GlLink, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + pipelineId() { + const id = getIdFromGraphQLId(this.job.pipeline.id); + return `#${id}`; + }, + pipelinePath() { + return this.job.pipeline?.path; + }, + pipelineUserAvatar() { + return this.job.pipeline?.user?.avatarUrl; + }, + userPath() { + return this.job.pipeline?.user?.webPath; + }, + showAvatar() { + return this.job.pipeline?.user; + }, + }, +}; +</script> + +<template> + <div class="gl-text-truncate"> + <gl-link class="gl-text-gray-500!" :href="pipelinePath" data-testid="pipeline-id"> + {{ pipelineId }} + </gl-link> + <div> + <span>{{ __('created by') }}</span> + <gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link"> + <gl-avatar :src="pipelineUserAvatar" :size="16" /> + </gl-link> + <span v-else>{{ __('API') }}</span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql index d9e51b0345a..c2104754bad 100644 --- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql +++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql @@ -8,7 +8,20 @@ query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) { startCursor } nodes { + artifacts { + nodes { + downloadPath + } + } + allowFailure + status + scheduledAt + manualJob + triggered + createdByTag detailedStatus { + detailsPath + group icon label text @@ -46,6 +59,10 @@ query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) { playable cancelable active + stuck + userPermissions { + readBuild + } } } } diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js index b6b3bb6d379..05d6ebfd6d6 100644 --- a/app/assets/javascripts/jobs/components/table/index.js +++ b/app/assets/javascripts/jobs/components/table/index.js @@ -16,13 +16,21 @@ export default (containerId = 'js-jobs-table') => { return false; } - const { fullPath, jobCounts, jobStatuses } = containerEl.dataset; + const { + fullPath, + jobCounts, + jobStatuses, + pipelineEditorPath, + emptyStateSvgPath, + } = containerEl.dataset; return new Vue({ el: containerEl, apolloProvider, provide: { + emptyStateSvgPath, fullPath, + pipelineEditorPath, jobStatuses: JSON.parse(jobStatuses), jobCounts: JSON.parse(jobCounts), }, diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue index 32b26d45dfe..4fe5bbf79cd 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue @@ -1,57 +1,81 @@ <script> import { GlTable } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { s__, __ } from '~/locale'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import ActionsCell from './cells/actions_cell.vue'; +import DurationCell from './cells/duration_cell.vue'; +import JobCell from './cells/job_cell.vue'; +import PipelineCell from './cells/pipeline_cell.vue'; const defaultTableClasses = { tdClass: 'gl-p-5!', thClass: 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!', }; +// eslint-disable-next-line @gitlab/require-i18n-strings +const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`; export default { + i18n: { + emptyText: s__('Jobs|No jobs to show'), + }, fields: [ { key: 'status', label: __('Status'), ...defaultTableClasses, + columnClass: 'gl-w-10p', }, { key: 'job', label: __('Job'), ...defaultTableClasses, + columnClass: 'gl-w-20p', }, { key: 'pipeline', label: __('Pipeline'), ...defaultTableClasses, + columnClass: 'gl-w-10p', }, { key: 'stage', label: __('Stage'), ...defaultTableClasses, + columnClass: 'gl-w-10p', }, { key: 'name', label: __('Name'), ...defaultTableClasses, + columnClass: 'gl-w-15p', }, { key: 'duration', label: __('Duration'), ...defaultTableClasses, + columnClass: 'gl-w-15p', }, { key: 'coverage', label: __('Coverage'), - ...defaultTableClasses, + tdClass: coverageTdClasses, + thClass: defaultTableClasses.thClass, + columnClass: 'gl-w-10p', }, { key: 'actions', label: '', ...defaultTableClasses, + columnClass: 'gl-w-10p', }, ], components: { + ActionsCell, + CiBadge, + DurationCell, GlTable, + JobCell, + PipelineCell, }, props: { jobs: { @@ -59,9 +83,64 @@ export default { required: true, }, }, + methods: { + formatCoverage(coverage) { + return coverage ? `${coverage}%` : ''; + }, + }, }; </script> <template> - <gl-table :items="jobs" :fields="$options.fields" /> + <gl-table + :items="jobs" + :fields="$options.fields" + :tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }" + :empty-text="$options.i18n.emptyText" + show-empty + stacked="lg" + fixed + > + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + + <template #cell(status)="{ item }"> + <ci-badge :status="item.detailedStatus" /> + </template> + + <template #cell(job)="{ item }"> + <job-cell :job="item" /> + </template> + + <template #cell(pipeline)="{ item }"> + <pipeline-cell :job="item" /> + </template> + + <template #cell(stage)="{ item }"> + <div class="gl-text-truncate"> + <span data-testid="job-stage-name">{{ item.stage.name }}</span> + </div> + </template> + + <template #cell(name)="{ item }"> + <div class="gl-text-truncate"> + <span data-testid="job-name">{{ item.name }}</span> + </div> + </template> + + <template #cell(duration)="{ item }"> + <duration-cell :job="item" /> + </template> + + <template #cell(coverage)="{ item }"> + <span v-if="item.coverage" data-testid="job-coverage">{{ + formatCoverage(item.coverage) + }}</span> + </template> + + <template #cell(actions)="{ item }"> + <actions-cell :job="item" /> + </template> + </gl-table> </template> diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue index 55954e31654..cf7970f41b1 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -3,6 +3,7 @@ import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; import { __ } from '~/locale'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; import JobsTable from './jobs_table.vue'; +import JobsTableEmptyState from './jobs_table_empty_state.vue'; import JobsTableTabs from './jobs_table_tabs.vue'; export default { @@ -13,6 +14,7 @@ export default { GlAlert, GlSkeletonLoader, JobsTable, + JobsTableEmptyState, JobsTableTabs, }, inject: { @@ -29,7 +31,7 @@ export default { }; }, update({ project }) { - return project?.jobs; + return project?.jobs?.nodes || []; }, error() { this.hasError = true; @@ -41,15 +43,21 @@ export default { jobs: null, hasError: false, isAlertDismissed: false, + scope: null, }; }, computed: { shouldShowAlert() { return this.hasError && !this.isAlertDismissed; }, + showEmptyState() { + return this.jobs.length === 0 && !this.scope; + }, }, methods: { fetchJobsByStatus(scope) { + this.scope = scope; + this.$apollo.queries.jobs.refetch({ statuses: scope }); }, }, @@ -80,6 +88,8 @@ export default { /> </div> - <jobs-table v-else :jobs="jobs.nodes" /> + <jobs-table-empty-state v-else-if="showEmptyState" /> + + <jobs-table v-else :jobs="jobs" /> </div> </template> diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_empty_state.vue b/app/assets/javascripts/jobs/components/table/jobs_table_empty_state.vue new file mode 100644 index 00000000000..fcdd52b719c --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/jobs_table_empty_state.vue @@ -0,0 +1,35 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('Jobs|Use jobs to automate your tasks'), + description: s__( + 'Jobs|Jobs are the building blocks of a GitLab CI/CD pipeline. Each job has a specific task, like testing code. To set up jobs in a CI/CD pipeline, add a CI/CD configuration file to your project.', + ), + buttonText: s__('Jobs|Create CI/CD configuration file'), + }, + components: { + GlEmptyState, + }, + inject: { + pipelineEditorPath: { + default: '', + }, + emptyStateSvgPath: { + default: '', + }, + }, +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :description="$options.i18n.description" + :svg-path="emptyStateSvgPath" + :primary-button-link="pipelineEditorPath" + :primary-button-text="$options.i18n.buttonText" + /> +</template> diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue index 95d265fce60..26791e4284d 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue @@ -50,7 +50,7 @@ export default { </script> <template> - <gl-tabs> + <gl-tabs content-class="gl-pb-0"> <gl-tab v-for="tab in tabs" :key="tab.text" diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js index d0d625d794d..3040d4e2379 100644 --- a/app/assets/javascripts/jobs/constants.js +++ b/app/assets/javascripts/jobs/constants.js @@ -22,3 +22,5 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { primaryText: __('Retry job'), title: s__('Jobs|Are you sure you want to retry this job?'), }; + +export const SUCCESS_STATUS = 'SUCCESS'; diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 3e00056ee81..260190f5043 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -13,6 +13,7 @@ export default () => { const { artifactHelpUrl, deploymentHelpUrl, + codeQualityHelpUrl, runnerSettingsUrl, variablesSettingsUrl, subscriptionsMoreMinutesUrl, @@ -38,6 +39,7 @@ export default () => { props: { artifactHelpUrl, deploymentHelpUrl, + codeQualityHelpUrl, runnerSettingsUrl, variablesSettingsUrl, subscriptionsMoreMinutesUrl, diff --git a/app/assets/javascripts/learn_gitlab/track_learn_gitlab.js b/app/assets/javascripts/learn_gitlab/track_learn_gitlab.js new file mode 100644 index 00000000000..305d130f10c --- /dev/null +++ b/app/assets/javascripts/learn_gitlab/track_learn_gitlab.js @@ -0,0 +1,10 @@ +import Tracking from '~/tracking'; + +export default function trackLearnGitlab(learnGitlabA) { + Tracking.event('projects:learn_gitlab:index', 'page_init', { + label: 'learn_gitlab', + property: learnGitlabA + ? 'Growth::Conversion::Experiment::LearnGitLabA' + : 'Growth::Activation::Experiment::LearnGitLabB', + }); +} diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index c720476f3bf..cec689a44ca 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -4,6 +4,7 @@ import { ApolloLink } from 'apollo-link'; import { BatchHttpLink } from 'apollo-link-batch-http'; import { createHttpLink } from 'apollo-link-http'; import { createUploadLink } from 'apollo-upload-client'; +import ActionCableLink from '~/actioncable_link'; import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; import csrf from '~/lib/utils/csrf'; @@ -18,11 +19,21 @@ export const fetchPolicies = { }; export default (resolvers = {}, config = {}) => { - let uri = `${gon.relative_url_root || ''}/api/graphql`; + const { + assumeImmutableResults, + baseUrl, + batchMax = 10, + cacheConfig, + fetchPolicy = fetchPolicies.CACHE_FIRST, + typeDefs, + path = '/api/graphql', + useGet = false, + } = config; + let uri = `${gon.relative_url_root || ''}${path}`; - if (config.baseUrl) { + if (baseUrl) { // Prepend baseUrl and ensure that `///` are replaced with `/` - uri = `${config.baseUrl}${uri}`.replace(/\/{3,}/g, '/'); + uri = `${baseUrl}${uri}`.replace(/\/{3,}/g, '/'); } const httpOptions = { @@ -34,7 +45,7 @@ export default (resolvers = {}, config = {}) => { // We set to `same-origin` which is default value in modern browsers. // See https://github.com/whatwg/fetch/pull/585 for more information. credentials: 'same-origin', - batchMax: config.batchMax || 10, + batchMax, }; const requestCounterLink = new ApolloLink((operation, forward) => { @@ -50,7 +61,7 @@ export default (resolvers = {}, config = {}) => { const uploadsLink = ApolloLink.split( (operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest, createUploadLink(httpOptions), - config.useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions), + useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions), ); const performanceBarLink = new ApolloLink((operation, forward) => { @@ -73,24 +84,36 @@ export default (resolvers = {}, config = {}) => { }); }); - return new ApolloClient({ - typeDefs: config.typeDefs, - link: ApolloLink.from([ + const hasSubscriptionOperation = ({ query: { definitions } }) => { + return definitions.some( + ({ kind, operation }) => kind === 'OperationDefinition' && operation === 'subscription', + ); + }; + + const appLink = ApolloLink.split( + hasSubscriptionOperation, + new ActionCableLink(), + ApolloLink.from([ requestCounterLink, performanceBarLink, new StartupJSLink(), apolloCaptchaLink, uploadsLink, ]), + ); + + return new ApolloClient({ + typeDefs, + link: appLink, cache: new InMemoryCache({ - ...config.cacheConfig, - freezeResults: config.assumeImmutableResults, + ...cacheConfig, + freezeResults: assumeImmutableResults, }), resolvers, - assumeImmutableResults: config.assumeImmutableResults, + assumeImmutableResults, defaultOptions: { query: { - fetchPolicy: config.fetchPolicy || fetchPolicies.CACHE_FIRST, + fetchPolicy, }, }, }); diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index fb257228597..8666d325c1b 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -645,9 +645,6 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => export const convertObjectPropsToSnakeCase = (obj = {}, options = {}) => convertObjectProps(convertToSnakeCase, obj, options); -export const imagePath = (imgUrl) => - `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`; - export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => { // Click a .js-select-on-focus field, select the contents // Prevent a mouseup event from deselecting the input diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index a509828815a..0a038febb9f 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -4,6 +4,8 @@ import { isString, mapValues, isNumber, reduce } from 'lodash'; import * as timeago from 'timeago.js'; import { languageCode, s__, __, n__ } from '../../locale'; +export const SECONDS_IN_DAY = 86400; + const DAYS_IN_WEEK = 7; window.timeago = timeago; diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js index 2a8b1759e54..bd47f10b3ac 100644 --- a/app/assets/javascripts/lib/utils/keys.js +++ b/app/assets/javascripts/lib/utils/keys.js @@ -1,2 +1,3 @@ export const ESC_KEY = 'Escape'; export const ENTER_KEY = 'Enter'; +export const BACKSPACE_KEY = 'Backspace'; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 63feb6f9b1d..e3500d02a79 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -171,3 +171,13 @@ export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-' export const isNumeric = (value) => { return !Number.isNaN(parseInt(value, 10)); }; + +const numberRegex = /^[0-9]+$/; + +/** + * Checks whether the value is a positive number or 0, or a string with equivalent value + * + * @param value + * @return {boolean} + */ +export const isPositiveInteger = (value) => numberRegex.test(value); diff --git a/app/assets/javascripts/lib/utils/recurrence.js b/app/assets/javascripts/lib/utils/recurrence.js new file mode 100644 index 00000000000..8fd26f3e393 --- /dev/null +++ b/app/assets/javascripts/lib/utils/recurrence.js @@ -0,0 +1,154 @@ +import { uuids } from './uuids'; + +/** + * @module recurrence + */ + +const instances = {}; + +/** + * Create a new unique {@link module:recurrence~RecurInstance|RecurInstance} + * @returns {module:recurrence.RecurInstance} The newly created {@link module:recurrence~RecurInstance|RecurInstance} + */ +export function create() { + const id = uuids()[0]; + let handlers = {}; + let count = 0; + + /** + * @namespace RecurInstance + * @description A RecurInstance tracks the count of any occurrence as registered by calls to <code>occur</code>. + * <br /><br /> + * It maintains an internal counter and a registry of handlers that can be arbitrarily assigned by a user. + * <br /><br /> + * While a RecurInstance isn't specific to any particular use-case, it may be useful for: + * <br /> + * <ul> + * <li>Tracking repeated errors across multiple - but not linked - network requests</li> + * <li>Tracking repeated user interactions (e.g. multiple clicks)</li> + * </ul> + * @summary A closure to track repeated occurrences of any arbitrary event. + * */ + const instance = { + /** + * @type {module:uuids~UUIDv4} + * @description A randomly generated {@link module:uuids~UUIDv4|UUID} for this particular recurrence instance + * @memberof module:recurrence~RecurInstance + * @readonly + * @inner + */ + get id() { + return id; + }, + /** + * @type {Number} + * @description The number of times this particular instance of recurrence has been triggered + * @memberof module:recurrence~RecurInstance + * @readonly + * @inner + */ + get count() { + return count; + }, + /** + * @type {Object} + * @description The handlers assigned to this recurrence tracker + * @example + * myRecurrence.handle( 4, () => console.log( "four" ) ); + * console.log( myRecurrence.handlers ); // {"4": () => console.log( "four" )} + * @memberof module:recurrence~RecurInstance + * @readonly + * @inner + */ + get handlers() { + return handlers; + }, + /** + * @type {Boolean} + * @description Delete any internal reference to the instance. + * <br /> + * Keep in mind that this will only attempt to remove the <strong>internal</strong> reference. + * <br /> + * If your code maintains a reference to the instance, the regular garbage collector will not free the memory. + * @memberof module:recurrence~RecurInstance + * @inner + */ + free() { + return delete instances[id]; + }, + /** + * @description Register a handler to be called when this occurrence is seen <code>onCount</code> number of times. + * @param {Number} onCount - The number of times the occurrence has been seen to respond to + * @param {Function} behavior - A callback function to run when the occurrence has been seen <code>onCount</code> times + * @memberof module:recurrence~RecurInstance + * @inner + */ + handle(onCount, behavior) { + if (onCount && behavior) { + handlers[onCount] = behavior; + } + }, + /** + * @description Remove the behavior callback handler that would be run when the occurrence is seen <code>onCount</code> times + * @param {Number} onCount - The count identifier for which to eject the callback handler + * @memberof module:recurrence~RecurInstance + * @inner + */ + eject(onCount) { + if (onCount) { + delete handlers[onCount]; + } + }, + /** + * @description Register that this occurrence has been seen and trigger any appropriate handlers + * @memberof module:recurrence~RecurInstance + * @inner + */ + occur() { + count += 1; + + if (typeof handlers[count] === 'function') { + handlers[count](count); + } + }, + /** + * @description Reset this recurrence instance without destroying it entirely + * @param {Object} [options] + * @param {Boolean} [options.currentCount = true] - Whether to reset the count + * @param {Boolean} [options.handlersList = false] - Whether to reset the list of attached handlers back to an empty state + * @memberof module:recurrence~RecurInstance + * @inner + */ + reset({ currentCount = true, handlersList = false } = {}) { + if (currentCount) { + count = 0; + } + + if (handlersList) { + handlers = {}; + } + }, + }; + + instances[id] = instance; + + return instance; +} + +/** + * Retrieve a stored {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID} + * @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance} + * @returns {(module:recurrence~RecurInstance|undefined)} The {@link module:recurrence~RecurInstance|RecurInstance}, or undefined if the UUID doesn't refer to a known Instance + */ +export function recall(id) { + return instances[id]; +} + +/** + * Release the memory space for a given {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID} + * @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance} + * @returns {Boolean} Whether the reference to the stored {@link module:recurrence~RecurInstance|RecurInstance} was released + */ +export function free(id) { + return recall(id)?.free() || false; +} diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 1593a363dd1..6ff2af47dd8 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -232,7 +232,7 @@ export function insertMarkdownText({ .join('\n'); } } else if (tag.indexOf(textPlaceholder) > -1) { - textToInsert = tag.replace(textPlaceholder, selected.replace(/\\n/g, '\n')); + textToInsert = tag.replace(textPlaceholder, () => selected.replace(/\\n/g, '\n')); } else { textToInsert = String(startChar) + tag + selected + (wrap ? tag : ''); } diff --git a/app/assets/javascripts/diffs/utils/uuids.js b/app/assets/javascripts/lib/utils/uuids.js index 98fe4bf9664..98fe4bf9664 100644 --- a/app/assets/javascripts/diffs/utils/uuids.js +++ b/app/assets/javascripts/lib/utils/uuids.js diff --git a/app/assets/javascripts/lib/utils/vuex_module_mappers.js b/app/assets/javascripts/lib/utils/vuex_module_mappers.js new file mode 100644 index 00000000000..95a794dd268 --- /dev/null +++ b/app/assets/javascripts/lib/utils/vuex_module_mappers.js @@ -0,0 +1,91 @@ +import { mapValues, isString } from 'lodash'; +import { mapState, mapActions } from 'vuex'; + +export const REQUIRE_STRING_ERROR_MESSAGE = + '`vuex_module_mappers` can only be used with an array of strings, or an object with string values. Consider using the regular `vuex` map helpers instead.'; + +const normalizeFieldsToObject = (fields) => { + return Array.isArray(fields) + ? fields.reduce((acc, key) => Object.assign(acc, { [key]: key }), {}) + : fields; +}; + +const mapVuexModuleFields = ({ namespaceSelector, fields, vuexHelper, selector } = {}) => { + // The `vuexHelper` needs an object which maps keys to field selector functions. + const map = mapValues(normalizeFieldsToObject(fields), (value) => { + if (!isString(value)) { + throw new Error(REQUIRE_STRING_ERROR_MESSAGE); + } + + // We need to use a good ol' function to capture the right "this". + return function mappedFieldSelector(...args) { + const namespace = namespaceSelector(this); + + return selector(namespace, value, ...args); + }; + }); + + return vuexHelper(map); +}; + +/** + * Like `mapState`, but takes a function in the first param for selecting a namespace. + * + * ``` + * computed: { + * ...mapVuexModuleState(vm => vm.vuexModule, ['foo']), + * } + * ``` + * + * @param {Function} namespaceSelector + * @param {Array|Object} fields + */ +export const mapVuexModuleState = (namespaceSelector, fields) => + mapVuexModuleFields({ + namespaceSelector, + fields, + vuexHelper: mapState, + selector: (namespace, value, state) => state[namespace][value], + }); + +/** + * Like `mapActions`, but takes a function in the first param for selecting a namespace. + * + * ``` + * methods: { + * ...mapVuexModuleActions(vm => vm.vuexModule, ['fetchFoos']), + * } + * ``` + * + * @param {Function} namespaceSelector + * @param {Array|Object} fields + */ +export const mapVuexModuleActions = (namespaceSelector, fields) => + mapVuexModuleFields({ + namespaceSelector, + fields, + vuexHelper: mapActions, + selector: (namespace, value, dispatch, ...args) => dispatch(`${namespace}/${value}`, ...args), + }); + +/** + * Like `mapGetters`, but takes a function in the first param for selecting a namespace. + * + * ``` + * computed: { + * ...mapGetters(vm => vm.vuexModule, ['hasSearchInfo']), + * } + * ``` + * + * @param {Function} namespaceSelector + * @param {Array|Object} fields + */ +export const mapVuexModuleGetters = (namespaceSelector, fields) => + mapVuexModuleFields({ + namespaceSelector, + fields, + // `mapGetters` does not let us pass an object which maps to functions. Thankfully `mapState` does + // and gives us access to the getters. + vuexHelper: mapState, + selector: (namespace, value, state, getters) => getters[`${namespace}/${value}`], + }); diff --git a/app/assets/javascripts/logs/components/log_advanced_filters.vue b/app/assets/javascripts/logs/components/log_advanced_filters.vue index 9159ca5b9dc..c6d7c9ad1dc 100644 --- a/app/assets/javascripts/logs/components/log_advanced_filters.vue +++ b/app/assets/javascripts/logs/components/log_advanced_filters.vue @@ -1,8 +1,9 @@ <script> import { GlFilteredSearch } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import { timeRanges } from '~/vue_shared/constants'; import { TOKEN_TYPE_POD_NAME } from '../constants'; import TokenWithLoadingState from './tokens/token_with_loading_state.vue'; @@ -54,7 +55,7 @@ export default { type: TOKEN_TYPE_POD_NAME, title: s__('Environments|Pod name'), token: TokenWithLoadingState, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, unique: true, options: this.podOptions, loading: this.logs.isLoading, diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js index e813f91d2fa..c3dc9f4bc12 100644 --- a/app/assets/javascripts/logs/stores/actions.js +++ b/app/assets/javascripts/logs/stores/actions.js @@ -127,7 +127,7 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => { return axios .get(environmentsPath) .then(({ data }) => { - commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments); + commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data); dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED); }) .catch(() => { diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 3f22bd36a4a..6200ade3595 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -35,6 +35,7 @@ import initUsagePingConsent from './usage_ping_consent'; import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; +import { initTopNav } from './nav'; import 'ee_else_ce/main_ee'; @@ -80,6 +81,7 @@ initRails(); function deferredInitialisation() { const $body = $('body'); + initTopNav(); initBreadcrumbs(); initTodoToggle(); initLogoAnimation(); diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue index cc97d235a9c..cc0533391df 100644 --- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -1,10 +1,11 @@ <script> import { GlFilteredSearchToken } from '@gitlab/ui'; import { mapState } from 'vuex'; -import { getParameterByName } from '~/lib/utils/common_utils'; -import { setUrlParams, queryToObject } from '~/lib/utils/url_utility'; +import { getParameterByName, urlParamsToObject } from '~/lib/utils/common_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import { SEARCH_TOKEN_TYPE, SORT_PARAM } from '~/members/constants'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; export default { @@ -17,7 +18,7 @@ export default { title: s__('Members|2FA'), token: GlFilteredSearchToken, unique: true, - operators: [{ value: '=', description: 'is' }], + operators: OPERATOR_IS_ONLY, options: [ { value: 'enabled', title: s__('Members|Enabled') }, { value: 'disabled', title: s__('Members|Disabled') }, @@ -30,7 +31,7 @@ export default { title: s__('Members|Membership'), token: GlFilteredSearchToken, unique: true, - operators: [{ value: '=', description: 'is' }], + operators: OPERATOR_IS_ONLY, options: [ { value: 'exclude', title: s__('Members|Direct') }, { value: 'only', title: s__('Members|Inherited') }, @@ -63,7 +64,7 @@ export default { }, }, created() { - const query = queryToObject(window.location.search); + const query = urlParamsToObject(window.location.search); const tokens = this.tokens .filter((token) => query[token.type]) @@ -97,9 +98,12 @@ export default { if (type === SEARCH_TOKEN_TYPE) { if (value.data !== '') { + const { searchParam } = this.filteredSearchBar; + const { [searchParam]: searchParamValue } = accumulator; + return { ...accumulator, - [this.filteredSearchBar.searchParam]: value.data, + [searchParam]: searchParamValue ? `${searchParamValue} ${value.data}` : value.data, }; } } else { diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue new file mode 100644 index 00000000000..37b9135126d --- /dev/null +++ b/app/assets/javascripts/members/components/members_tabs.vue @@ -0,0 +1,124 @@ +<script> +import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; +import { mapState } from 'vuex'; +import { urlParamsToObject } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import { MEMBER_TYPES } from '../constants'; +import MembersApp from './app.vue'; + +const countComputed = (state, namespace) => state[namespace]?.pagination?.totalItems || 0; + +export default { + name: 'MembersTabs', + tabs: [ + { + namespace: MEMBER_TYPES.user, + title: __('Members'), + }, + { + namespace: MEMBER_TYPES.group, + title: __('Groups'), + attrs: { 'data-qa-selector': 'groups_list_tab' }, + }, + { + namespace: MEMBER_TYPES.invite, + title: __('Invited'), + canManageMembersPermissionsRequired: true, + }, + { + namespace: MEMBER_TYPES.accessRequest, + title: __('Access requests'), + canManageMembersPermissionsRequired: true, + }, + ], + urlParams: [], + components: { MembersApp, GlTabs, GlTab, GlBadge }, + inject: ['canManageMembers'], + data() { + return { + selectedTabIndex: 0, + }; + }, + computed: { + ...mapState({ + userCount(state) { + return countComputed(state, MEMBER_TYPES.user); + }, + groupCount(state) { + return countComputed(state, MEMBER_TYPES.group); + }, + inviteCount(state) { + return countComputed(state, MEMBER_TYPES.invite); + }, + accessRequestCount(state) { + return countComputed(state, MEMBER_TYPES.accessRequest); + }, + }), + urlParams() { + return Object.keys(urlParamsToObject(window.location.search)); + }, + activeTabIndexCalculatedFromUrlParams() { + return this.$options.tabs.findIndex(({ namespace }) => { + return this.getTabUrlParams(namespace).some((urlParam) => + this.urlParams.includes(urlParam), + ); + }); + }, + }, + created() { + if (this.activeTabIndexCalculatedFromUrlParams === -1) { + return; + } + + this.selectedTabIndex = this.activeTabIndexCalculatedFromUrlParams; + }, + methods: { + getTabUrlParams(namespace) { + const state = this.$store.state[namespace]; + const urlParams = []; + + if (state?.pagination?.paramName) { + urlParams.push(state.pagination.paramName); + } + + if (state?.filteredSearchBar?.searchParam) { + urlParams.push(state.filteredSearchBar.searchParam); + } + + return urlParams; + }, + getTabCount({ namespace }) { + return this[`${namespace}Count`]; + }, + showTab(tab, index) { + if (tab.namespace === MEMBER_TYPES.user) { + return true; + } + + const { canManageMembersPermissionsRequired = false } = tab; + const tabCanBeShown = + this.getTabCount(tab) > 0 || this.activeTabIndexCalculatedFromUrlParams === index; + + if (canManageMembersPermissionsRequired) { + return this.canManageMembers && tabCanBeShown; + } + + return tabCanBeShown; + }, + }, +}; +</script> + +<template> + <gl-tabs v-model="selectedTabIndex"> + <template v-for="(tab, index) in $options.tabs"> + <gl-tab v-if="showTab(tab, index)" :key="tab.namespace" :title-link-attributes="tab.attrs"> + <template slot="title"> + <span>{{ tab.title }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ getTabCount(tab) }}</gl-badge> + </template> + <members-app :namespace="tab.namespace" /> + </gl-tab> + </template> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 236aeaef418..09ef98ec411 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -1,8 +1,9 @@ <script> -import { GlTable, GlBadge } from '@gitlab/ui'; +import { GlTable, GlBadge, GlPagination } from '@gitlab/ui'; import { mapState } from 'vuex'; import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue'; import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; import initUserPopovers from '~/user_popovers'; import { FIELDS } from '../../constants'; import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; @@ -19,6 +20,7 @@ export default { components: { GlTable, GlBadge, + GlPagination, MemberAvatar, CreatedAt, ExpiresAt, @@ -43,6 +45,9 @@ export default { tableAttrs(state) { return state[this.namespace].tableAttrs; }, + pagination(state) { + return state[this.namespace].pagination; + }, }), filteredFields() { return FIELDS.filter( @@ -59,6 +64,11 @@ export default { userIsLoggedIn() { return this.currentUserId !== null; }, + showPagination() { + const { paramName, currentPage, perPage, totalItems } = this.pagination; + + return paramName && currentPage && perPage && totalItems; + }, }, mounted() { initUserPopovers(this.$el.querySelectorAll('.js-user-link')); @@ -99,6 +109,11 @@ export default { ...(member?.id && { 'data-testid': `members-table-row-${member.id}` }), }; }, + paginationLinkGenerator(page) { + const { params = {}, paramName } = this.pagination; + + return mergeUrlParams({ ...params, [paramName]: page }, window.location.href); + }, }, }; </script> @@ -119,6 +134,9 @@ export default { show-empty :tbody-tr-attr="tbodyTrAttr" > + <template #head()="{ label }"> + {{ label }} + </template> <template #cell(account)="{ item: member }"> <members-table-cell #default="{ memberType, isCurrentUser }" :member="member"> <member-avatar @@ -179,6 +197,18 @@ export default { <span data-testid="col-actions" class="gl-sr-only">{{ label }}</span> </template> </gl-table> + <gl-pagination + v-if="showPagination" + :value="pagination.currentPage" + :per-page="pagination.perPage" + :total-items="pagination.totalItems" + :link-gen="paginationLinkGenerator" + :prev-text="__('Prev')" + :next-text="__('Next')" + :label-next-page="__('Go to next page')" + :label-prev-page="__('Go to previous page')" + align="center" + /> <remove-group-link-modal /> <ldap-override-confirmation-modal /> </div> diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js index 6376b3fa75a..6c913af8a0f 100644 --- a/app/assets/javascripts/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -1,7 +1,7 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; -import { parseDataAttributes } from 'ee_else_ce/members/utils'; +import { parseDataAttributes } from '~/members/utils'; import App from './components/app.vue'; import membersStore from './store'; diff --git a/app/assets/javascripts/members/store/state.js b/app/assets/javascripts/members/store/state.js index 4006b4b501d..5415b1c5f25 100644 --- a/app/assets/javascripts/members/store/state.js +++ b/app/assets/javascripts/members/store/state.js @@ -1,5 +1,6 @@ export default ({ members, + pagination, tableFields, tableAttrs, tableSortableFields, @@ -8,6 +9,7 @@ export default ({ filteredSearchBar, }) => ({ members, + pagination, tableFields, tableAttrs, tableSortableFields, diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js index 2bf30dd7b6e..be549b40885 100644 --- a/app/assets/javascripts/members/utils.js +++ b/app/assets/javascripts/members/utils.js @@ -1,9 +1,5 @@ import { isUndefined } from 'lodash'; -import { - getParameterByName, - convertObjectPropsToCamelCase, - parseBoolean, -} from '~/lib/utils/common_utils'; +import { getParameterByName, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { @@ -105,14 +101,12 @@ export const buildSortHref = ({ export const canOverride = () => false; export const parseDataAttributes = (el) => { - const { members, sourceId, memberPath, canManageMembers } = el.dataset; + const { membersData } = el.dataset; - return { - members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }), - sourceId: parseInt(sourceId, 10), - memberPath, - canManageMembers: parseBoolean(canManageMembers), - }; + return convertObjectPropsToCamelCase(JSON.parse(membersData), { + deep: true, + ignoreKeyNames: ['params'], + }); }; export const baseRequestFormatter = (basePropertyName, accessLevelPropertyName) => ({ diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue index 7649c363daa..04e493712ec 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue @@ -1,4 +1,5 @@ <script> +import { GlButton } from '@gitlab/ui'; import { debounce } from 'lodash'; import { mapActions } from 'vuex'; import { deprecatedCreateFlash as flash } from '~/flash'; @@ -7,6 +8,9 @@ import { __ } from '~/locale'; import { INTERACTIVE_RESOLVE_MODE } from '../constants'; export default { + components: { + GlButton, + }, props: { file: { type: Object, @@ -100,21 +104,21 @@ export default { }; </script> <template> - <div v-show="file.showEditor" class="diff-editor-wrap"> - <div v-if="file.promptDiscardConfirmation" class="discard-changes-alert-wrap"> - <div class="discard-changes-alert"> - {{ __('Are you sure you want to discard your changes?') }} - <div class="discard-actions"> - <button - class="btn btn-sm btn-danger-secondary gl-button" - @click="acceptDiscardConfirmation(file)" - > - {{ __('Discard changes') }} - </button> - <button class="btn btn-default btn-sm gl-button" @click="cancelDiscardConfirmation(file)"> - {{ __('Cancel') }} - </button> - </div> + <div v-show="file.showEditor"> + <div v-if="file.promptDiscardConfirmation" class="discard-changes-alert"> + {{ __('Are you sure you want to discard your changes?') }} + <div class="gl-ml-3 gl-display-inline-block"> + <gl-button + size="small" + variant="danger" + category="secondary" + @click="acceptDiscardConfirmation(file)" + > + {{ __('Discard changes') }} + </gl-button> + <gl-button size="small" @click="cancelDiscardConfirmation(file)"> + {{ __('Cancel') }} + </gl-button> </div> </div> <div :class="classObject" class="editor-wrap"> diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue index 9721481e6be..a856d38c089 100644 --- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue @@ -35,7 +35,7 @@ export default { <td :class="lineCssClass(line)" class="diff-line-num header"></td> <td :class="lineCssClass(line)" class="line_content header"> <strong>{{ line.richText }}</strong> - <button class="btn" @click="handleSelected({ file, line })"> + <button type="button" @click="handleSelected({ file, line })"> {{ line.buttonTitle }} </button> </td> diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue index 7b1d947ccff..2c89b614c87 100644 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue @@ -35,7 +35,7 @@ export default { <td class="diff-line-num header" :class="lineCssClass(line)"></td> <td class="line_content header" :class="lineCssClass(line)"> <strong>{{ line.richText }}</strong> - <button class="btn" @click="handleSelected({ file, line })"> + <button type="button" @click="handleSelected({ file, line })"> {{ line.buttonTitle }} </button> </td> diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue index 0509cf0afa1..3e31e2e93ae 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue @@ -1,5 +1,5 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlButton, GlButtonGroup } from '@gitlab/ui'; import { mapGetters, mapState, mapActions } from 'vuex'; import { __ } from '~/locale'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -20,6 +20,8 @@ import { INTERACTIVE_RESOLVE_MODE } from './constants'; */ export default { components: { + GlButton, + GlButtonGroup, GlSprintf, FileIcon, DiffFileEditor, @@ -77,28 +79,23 @@ export default { {{ conflictsData.errorMessage }} </div> <template v-if="!isLoading && !hasError"> - <div class="content-block oneline-block files-changed"> - <div v-if="fileTextTypePresent" class="inline-parallel-buttons"> - <div class="btn-group"> - <button - :class="{ active: !isParallel }" - class="btn gl-button" - @click="setViewType('inline')" - > + <div class="gl-border-b-0 gl-py-5 gl-line-height-32"> + <div v-if="fileTextTypePresent" class="gl-float-right"> + <gl-button-group> + <gl-button :selected="!isParallel" @click="setViewType('inline')"> {{ __('Inline') }} - </button> - <button - :class="{ active: isParallel }" - class="btn gl-button" + </gl-button> + <gl-button + :selected="isParallel" data-testid="side-by-side" @click="setViewType('parallel')" > {{ __('Side-by-side') }} - </button> - </div> + </gl-button> + </gl-button-group> </div> <div class="js-toggle-container"> - <div class="commit-stat-summary" data-testid="conflicts-count"> + <div data-testid="conflicts-count"> <gl-sprintf :message="$options.i18n.commitStatSummary"> <template #conflict> <strong class="cred">{{ getConflictsCountText }}</strong> @@ -127,47 +124,43 @@ export default { <strong class="file-title-name">{{ file.filePath }}</strong> </div> <div class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start"> - <div v-if="file.type === 'text'" class="btn-group gl-mr-3"> - <button - :class="{ active: file.resolveMode === 'interactive' }" - class="btn gl-button" - type="button" + <gl-button-group v-if="file.type === 'text'" class="gl-mr-3"> + <gl-button + :selected="file.resolveMode === 'interactive'" data-testid="interactive-button" @click="onClickResolveModeButton(file, 'interactive')" > {{ __('Interactive mode') }} - </button> - <button - :class="{ active: file.resolveMode === 'edit' }" - class="btn gl-button" - type="button" + </gl-button> + <gl-button + :selected="file.resolveMode === 'edit'" data-testid="inline-button" @click="onClickResolveModeButton(file, 'edit')" > {{ __('Edit inline') }} - </button> - </div> - <a :href="file.blobPath" class="btn gl-button view-file"> + </gl-button> + </gl-button-group> + <gl-button :href="file.blobPath"> <gl-sprintf :message="__('View file @ %{commitSha}')"> <template #commitSha> {{ conflictsData.shortCommitSha }} </template> </gl-sprintf> - </a> + </gl-button> </div> </div> <div class="diff-content diff-wrap-lines"> - <template v-if="file.resolveMode === 'interactive' && file.type === 'text'"> - <div v-if="!isParallel" class="file-content"> - <inline-conflict-lines :file="file" /> - </div> - <div v-if="isParallel" class="file-content"> - <parallel-conflict-lines :file="file" /> - </div> - </template> - <div v-if="file.resolveMode === 'edit' || file.type === 'text-editor'"> - <diff-file-editor :file="file" /> + <div + v-if="file.resolveMode === 'interactive' && file.type === 'text'" + class="file-content" + > + <parallel-conflict-lines v-if="isParallel" :file="file" /> + <inline-conflict-lines v-else :file="file" /> </div> + <diff-file-editor + v-if="file.resolveMode === 'edit' || file.type === 'text-editor'" + :file="file" + /> </div> </div> </div> @@ -176,10 +169,10 @@ export default { <div class="resolve-conflicts-form"> <div class="form-group row"> <div class="col-md-4"> - <h4> + <h4 class="gl-mt-0"> {{ __('Resolve conflicts on source branch') }} </h4> - <div class="resolve-info"> + <div class="gl-mb-5" data-testid="resolve-info"> <gl-sprintf :message="$options.i18n.resolveInfo"> <template #use_ours> <code>{{ s__('MergeConflict|Use ours') }}</code> @@ -199,7 +192,7 @@ export default { <label class="label-bold" for="commit-message"> {{ __('Commit message') }} </label> - <div class="commit-message-container"> + <div class="commit-message-container gl-mb-4"> <div class="max-width-marker"></div> <textarea id="commit-message" @@ -209,27 +202,17 @@ export default { rows="5" ></textarea> </div> - </div> - </div> - <div class="form-group row"> - <div class="offset-md-4 col-md-8"> - <div class="row"> - <div class="col-6"> - <button - :disabled="!isReadyToCommit" - class="btn gl-button btn-success js-submit-button" - type="button" - @click="submitResolvedConflicts(resolveConflictsPath)" - > - <span>{{ getCommitButtonText }}</span> - </button> - </div> - <div class="col-6 text-right"> - <a :href="mergeRequestPath" class="gl-button btn btn-default"> - {{ __('Cancel') }} - </a> - </div> - </div> + <gl-button + :disabled="!isReadyToCommit" + variant="confirm" + class="js-submit-button" + @click="submitResolvedConflicts(resolveConflictsPath)" + > + {{ getCommitButtonText }} + </gl-button> + <gl-button :href="mergeRequestPath"> + {{ __('Cancel') }} + </gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/merge_request/components/status_box.vue b/app/assets/javascripts/merge_request/components/status_box.vue deleted file mode 100644 index 526aafc1def..00000000000 --- a/app/assets/javascripts/merge_request/components/status_box.vue +++ /dev/null @@ -1,71 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import { __ } from '~/locale'; -import mrEventHub from '../eventhub'; - -const CLASSES = { - opened: 'status-box-open', - locked: 'status-box-open', - closed: 'status-box-mr-closed', - merged: 'status-box-mr-merged', -}; - -const STATUS = { - opened: [__('Open'), 'issue-open-m'], - locked: [__('Open'), 'issue-open-m'], - closed: [__('Closed'), 'issue-close'], - merged: [__('Merged'), 'git-merge'], -}; - -export default { - components: { - GlIcon, - }, - props: { - initialState: { - type: String, - required: true, - }, - }, - data() { - return { - state: this.initialState, - }; - }, - computed: { - statusBoxClass() { - return CLASSES[this.state]; - }, - statusHumanName() { - return STATUS[this.state][0]; - }, - statusIconName() { - return STATUS[this.state][1]; - }, - }, - created() { - mrEventHub.$on('mr.state.updated', this.updateState); - }, - beforeDestroy() { - mrEventHub.$off('mr.state.updated', this.updateState); - }, - methods: { - updateState({ state }) { - this.state = state; - }, - }, -}; -</script> - -<template> - <div :class="statusBoxClass" class="issuable-status-box status-box"> - <gl-icon - :name="statusIconName" - class="gl-display-block gl-sm-display-none!" - data-testid="status-icon" - /> - <span class="gl-display-none gl-sm-display-block"> - {{ statusHumanName }} - </span> - </div> -</template> diff --git a/app/assets/javascripts/merge_request/eventhub.js b/app/assets/javascripts/merge_request/eventhub.js deleted file mode 100644 index e31806ad199..00000000000 --- a/app/assets/javascripts/merge_request/eventhub.js +++ /dev/null @@ -1,3 +0,0 @@ -import createEventHub from '~/helpers/event_hub_factory'; - -export default createEventHub(); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 67b24793a65..d5db9f43d09 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -355,6 +355,8 @@ export default class MergeRequestTabs { this.commitPipelinesTable = new Vue({ provide: { + artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint, + artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder, targetProjectFullPath: mrWidgetData?.target_project_full_path || '', }, render(createElement) { diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index bfb18206b62..05e7fb7a3e9 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -8,6 +8,7 @@ import invalidUrl from '~/lib/utils/invalid_url'; import { ESC_KEY } from '~/lib/utils/keys'; import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; +import AlertsDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue'; import { defaultTimeRange } from '~/vue_shared/constants'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { metricStates, keyboardShortcutKeys } from '../constants'; @@ -28,6 +29,7 @@ import VariablesSection from './variables_section.vue'; export default { components: { + AlertsDeprecationWarning, VueDraggable, DashboardHeader, DashboardPanel, @@ -394,6 +396,8 @@ export default { <template> <div class="prometheus-graphs" data-qa-selector="prometheus_graphs"> + <alerts-deprecation-warning /> + <dashboard-header v-if="showHeader" ref="prometheusGraphsHeader" diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js index ee67e5dd827..cf79e71b9e0 100644 --- a/app/assets/javascripts/monitoring/monitoring_app.js +++ b/app/assets/javascripts/monitoring/monitoring_app.js @@ -12,7 +12,10 @@ export default (props = {}) => { if (el && el.dataset) { const { metricsDashboardBasePath, ...dataset } = el.dataset; - const { initState, dataProps } = stateAndPropsFromDataset(dataset); + const { + initState, + dataProps: { hasManagedPrometheus, ...dataProps }, + } = stateAndPropsFromDataset(dataset); const store = createStore(initState); const router = createRouter(metricsDashboardBasePath); @@ -21,6 +24,7 @@ export default (props = {}) => { el, store, router, + provide: { hasManagedPrometheus }, data() { return { dashboardProps: { ...dataProps, ...props }, diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 6306415a8b9..8adf1862af2 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -41,6 +41,7 @@ export const stateAndPropsFromDataset = (dataset = {}) => { dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics); dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable); dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable); + dataProps.hasManagedPrometheus = parseBoolean(dataProps.hasManagedPrometheus); return { initState: { diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue new file mode 100644 index 00000000000..f8f3ba26536 --- /dev/null +++ b/app/assets/javascripts/nav/components/top_nav_app.vue @@ -0,0 +1,59 @@ +<script> +import { GlNav, GlNavItemDropdown, GlDropdownForm, GlTooltip } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import TopNavDropdownMenu from './top_nav_dropdown_menu.vue'; + +const TOOLTIP = s__('TopNav|Switch to...'); + +export default { + components: { + GlNav, + GlNavItemDropdown, + GlDropdownForm, + GlTooltip, + TopNavDropdownMenu, + }, + props: { + navData: { + type: Object, + required: true, + }, + }, + methods: { + findTooltipTarget() { + // ### Why use a target function instead of `v-gl-tooltip`? + // To get the tooltip to align correctly, we need it to target the actual + // toggle button which we don't directly render. + return this.$el.querySelector('.js-top-nav-dropdown-toggle'); + }, + }, + TOOLTIP, +}; +</script> + +<template> + <gl-nav class="navbar-sub-nav"> + <gl-nav-item-dropdown + :text="navData.activeTitle" + icon="dot-grid" + menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto!" + toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!" + no-flip + > + <gl-dropdown-form> + <top-nav-dropdown-menu + :primary="navData.primary" + :secondary="navData.secondary" + :views="navData.views" + /> + </gl-dropdown-form> + </gl-nav-item-dropdown> + <gl-tooltip + boundary="window" + :boundary-padding="0" + :target="findTooltipTarget" + placement="right" + :title="$options.TOOLTIP" + /> + </gl-nav> +</template> diff --git a/app/assets/javascripts/nav/components/top_nav_container_view.vue b/app/assets/javascripts/nav/components/top_nav_container_view.vue new file mode 100644 index 00000000000..21ff3ebcd7d --- /dev/null +++ b/app/assets/javascripts/nav/components/top_nav_container_view.vue @@ -0,0 +1,74 @@ +<script> +import FrequentItemsApp from '~/frequent_items/components/app.vue'; +import eventHub from '~/frequent_items/event_hub'; +import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue'; +import TopNavMenuItem from './top_nav_menu_item.vue'; + +export default { + components: { + FrequentItemsApp, + TopNavMenuItem, + VuexModuleProvider, + }, + props: { + frequentItemsVuexModule: { + type: String, + required: true, + }, + frequentItemsDropdownType: { + type: String, + required: true, + }, + linksPrimary: { + type: Array, + required: false, + default: () => [], + }, + linksSecondary: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + linkGroups() { + return [ + { key: 'primary', links: this.linksPrimary }, + { key: 'secondary', links: this.linksSecondary }, + ].filter((x) => x.links?.length); + }, + }, + mounted() { + // For historic reasons, the frequent-items-app component requires this too start up. + this.$nextTick(() => { + eventHub.$emit(`${this.frequentItemsDropdownType}-dropdownOpen`); + }); + }, +}; +</script> + +<template> + <div class="top-nav-container-view gl-display-flex gl-flex-direction-column"> + <div class="frequent-items-dropdown-container gl-w-auto"> + <div class="frequent-items-dropdown-content gl-w-full! gl-pt-0!"> + <vuex-module-provider :vuex-module="frequentItemsVuexModule"> + <frequent-items-app v-bind="$attrs" /> + </vuex-module-provider> + </div> + </div> + <div + v-for="({ key, links }, groupIndex) in linkGroups" + :key="key" + :class="{ 'gl-mt-3': groupIndex !== 0 }" + class="gl-mt-auto gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-100" + data-testid="menu-item-group" + > + <top-nav-menu-item + v-for="(link, linkIndex) in links" + :key="link.title" + :menu-item="link" + :class="{ 'gl-mt-1': linkIndex !== 0 }" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue new file mode 100644 index 00000000000..1cbd64b501d --- /dev/null +++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue @@ -0,0 +1,144 @@ +<script> +import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants'; +import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue'; +import TopNavContainerView from './top_nav_container_view.vue'; +import TopNavMenuItem from './top_nav_menu_item.vue'; + +const ACTIVE_CLASS = 'gl-shadow-none! gl-font-weight-bold! active'; +const SECONDARY_GROUP_CLASS = 'gl-pt-3 gl-mt-3 gl-border-1 gl-border-t-solid gl-border-gray-100'; + +export default { + components: { + KeepAliveSlots, + TopNavContainerView, + TopNavMenuItem, + }, + props: { + primary: { + type: Array, + required: false, + default: () => [], + }, + secondary: { + type: Array, + required: false, + default: () => [], + }, + views: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + activeId: '', + }; + }, + computed: { + menuItemGroups() { + return [ + { key: 'primary', items: this.primary, classes: '' }, + { + key: 'secondary', + items: this.secondary, + classes: SECONDARY_GROUP_CLASS, + }, + ].filter((x) => x.items?.length); + }, + allMenuItems() { + return this.menuItemGroups.flatMap((x) => x.items); + }, + activeMenuItem() { + return this.allMenuItems.find((x) => x.id === this.activeId); + }, + activeView() { + return this.activeMenuItem?.view; + }, + menuClass() { + if (!this.activeView) { + return 'gl-w-full'; + } + + return ''; + }, + }, + created() { + // Initialize activeId based on initialization prop + this.activeId = this.allMenuItems.find((x) => x.active)?.id; + }, + methods: { + onClick({ id, href }) { + // If we're a link, let's just do the default behavior so the view won't change + if (href) { + return; + } + + this.activeId = id; + }, + menuItemClasses(menuItem) { + if (menuItem.id === this.activeId) { + return ACTIVE_CLASS; + } + + return ''; + }, + }, + FREQUENT_ITEMS_PROJECTS, + FREQUENT_ITEMS_GROUPS, + // expose for unit tests + ACTIVE_CLASS, + SECONDARY_GROUP_CLASS, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-stretch"> + <div + class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10" + :class="menuClass" + data-testid="menu-sidebar" + > + <div + class="gl-py-3 gl-px-5 gl-h-full gl-display-flex gl-align-items-stretch gl-flex-direction-column" + > + <div + v-for="group in menuItemGroups" + :key="group.key" + :class="group.classes" + data-testid="menu-item-group" + > + <top-nav-menu-item + v-for="(menu, index) in group.items" + :key="menu.id" + data-testid="menu-item" + :class="[{ 'gl-mt-1': index !== 0 }, menuItemClasses(menu)]" + :menu-item="menu" + @click="onClick(menu)" + /> + </div> + </div> + </div> + <keep-alive-slots + v-show="activeView" + :slot-key="activeView" + class="gl-w-grid-size-40 gl-overflow-hidden gl-py-3 gl-px-5" + data-testid="menu-subview" + > + <template #projects> + <top-nav-container-view + :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_PROJECTS.namespace" + :frequent-items-vuex-module="$options.FREQUENT_ITEMS_PROJECTS.vuexModule" + v-bind="views.projects" + /> + </template> + <template #groups> + <top-nav-container-view + :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_GROUPS.namespace" + :frequent-items-vuex-module="$options.FREQUENT_ITEMS_GROUPS.vuexModule" + v-bind="views.groups" + /> + </template> + </keep-alive-slots> + </div> +</template> diff --git a/app/assets/javascripts/nav/components/top_nav_menu_item.vue b/app/assets/javascripts/nav/components/top_nav_menu_item.vue new file mode 100644 index 00000000000..a0d92811a6f --- /dev/null +++ b/app/assets/javascripts/nav/components/top_nav_menu_item.vue @@ -0,0 +1,31 @@ +<script> +import { GlButton, GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlButton, + GlIcon, + }, + props: { + menuItem: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <gl-button + category="tertiary" + :href="menuItem.href" + class="top-nav-menu-item gl-display-block" + v-on="$listeners" + > + <span class="gl-display-flex"> + <gl-icon v-if="menuItem.icon" :name="menuItem.icon" class="gl-mr-2!" /> + {{ menuItem.title }} + <gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" /> + </span> + </gl-button> +</template> diff --git a/app/assets/javascripts/nav/index.js b/app/assets/javascripts/nav/index.js new file mode 100644 index 00000000000..646ce3f0ecf --- /dev/null +++ b/app/assets/javascripts/nav/index.js @@ -0,0 +1,12 @@ +export const initTopNav = async () => { + const el = document.getElementById('js-top-nav'); + + if (!el) { + return; + } + + // With combined_menu feature flag, there's a benefit to splitting up the import + const { mountTopNav } = await import(/* webpackChunkName: 'top_nav' */ './mount'); + + mountTopNav(el); +}; diff --git a/app/assets/javascripts/nav/mount.js b/app/assets/javascripts/nav/mount.js new file mode 100644 index 00000000000..0d46ff56249 --- /dev/null +++ b/app/assets/javascripts/nav/mount.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import App from './components/top_nav_app.vue'; +import { createStore } from './stores'; + +Vue.use(Vuex); + +export const mountTopNav = (el) => { + const viewModel = JSON.parse(el.dataset.viewModel); + const store = createStore(); + + return new Vue({ + el, + store, + render(h) { + return h(App, { + props: { + navData: viewModel, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/nav/stores/index.js b/app/assets/javascripts/nav/stores/index.js new file mode 100644 index 00000000000..527bbdd5c3f --- /dev/null +++ b/app/assets/javascripts/nav/stores/index.js @@ -0,0 +1,4 @@ +import Vuex from 'vuex'; +import { createStoreOptions } from '~/frequent_items/store'; + +export const createStore = () => new Vuex.Store(createStoreOptions()); diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index c09db6851e5..9bf26e5a182 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -3,6 +3,7 @@ import katex from 'katex'; import marked from 'marked'; import { sanitize } from '~/lib/dompurify'; +import { hasContent } from '~/lib/utils/text_utility'; import Prompt from './prompt.vue'; const renderer = new marked.Renderer(); @@ -88,6 +89,38 @@ renderer.listitem = (t) => { const [text, inline] = renderKatex(t); return `<li class="${inline ? 'inline-katex' : ''}">${text}</li>`; }; +renderer.originalImage = renderer.image; + +renderer.image = function image(href, title, text) { + const attachmentHeader = `attachment:`; // eslint-disable-line @gitlab/require-i18n-strings + + if (!this.attachments || !href.startsWith(attachmentHeader)) { + return this.originalImage(href, title, text); + } + + let img = ``; + const filename = href.substring(attachmentHeader.length); + + if (hasContent(filename)) { + const attachment = this.attachments[filename]; + + if (attachment) { + const imageType = Object.keys(attachment)[0]; + + if (hasContent(imageType)) { + const data = attachment[imageType]; + const inlined = `data:${imageType};base64,${data}"`; // eslint-disable-line @gitlab/require-i18n-strings + img = this.originalImage(inlined, title, text); + } + } + } + + if (!hasContent(img)) { + return this.originalImage(href, title, text); + } + + return sanitize(img); +}; marked.setOptions({ renderer, @@ -105,6 +138,8 @@ export default { }, computed: { markdown() { + renderer.attachments = this.cell.attachments; + return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), { // allowedTags from GitLab's inline HTML guidelines // https://docs.gitlab.com/ee/user/markdown.html#inline-html diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index b5c59f34e87..c324c846f47 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1732,7 +1732,7 @@ export default class Notes { // Submission failed, revert back to original note $noteBodyText.html(escape(cachedNoteBodyText)); $editingNote.removeClass('being-posted fade-in'); - $editingNote.find('.spinner').remove(); + $editingNote.find('.gl-spinner').remove(); // Show Flash message about failure this.updateNoteError(); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 79d8ce78329..90be5b3e470 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -15,6 +15,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import Autosave from '~/autosave'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { deprecatedCreateFlash as Flash } from '~/flash'; +import { statusBoxState } from '~/issuable/components/status_box.vue'; import httpStatusCodes from '~/lib/utils/http_status'; import { capitalizeFirstCharacter, @@ -162,7 +163,7 @@ export default { canToggleIssueState() { return ( this.getNoteableData.current_user.can_update && - this.getNoteableData.state !== constants.MERGED && + this.openState !== constants.MERGED && !this.closedAndLocked ); }, @@ -283,6 +284,7 @@ export default { const toggleState = this.isOpen ? this.closeIssuable : this.reopenIssuable; toggleState() + .then(() => statusBoxState.updateStatus && statusBoxState.updateStatus()) .then(refreshUserMergeRequestCounts) .catch(() => Flash(constants.toggleStateErrorMessage[this.noteableType][this.openState])); }, diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 24399e669a6..0cc818c6d0e 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -278,7 +278,6 @@ export default { v-if="canResolve" ref="resolveButton" v-gl-tooltip - size="small" category="tertiary" :variant="resolveVariant" :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" @@ -292,7 +291,7 @@ export default { <template v-if="canAwardEmoji"> <emoji-picker v-if="glFeatures.improvedEmojiPicker" - toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-2 gl-p-0! gl-shadow-none! gl-bg-transparent!" + toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-3 gl-p-0! gl-shadow-none! gl-bg-transparent!" @click="setAwardEmoji" > <template #button-content> @@ -305,10 +304,9 @@ export default { v-else v-gl-tooltip :class="{ 'js-user-authored': isAuthoredByCurrentUser }" - class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji" + class="note-action-button note-emoji-button add-reaction-button btn-icon js-add-award js-note-emoji" category="tertiary" variant="default" - size="small" :title="$options.i18n.addReactionLabel" :aria-label="$options.i18n.addReactionLabel" data-position="right" @@ -336,7 +334,6 @@ export default { :title="$options.i18n.editCommentLabel" :aria-label="$options.i18n.editCommentLabel" icon="pencil" - size="small" category="tertiary" class="note-action-button js-note-edit" data-qa-selector="note_edit_button" @@ -347,24 +344,24 @@ export default { v-gl-tooltip :title="$options.i18n.deleteCommentLabel" :aria-label="$options.i18n.deleteCommentLabel" - size="small" icon="remove" category="tertiary" class="note-action-button js-note-delete" @click="onDelete" /> <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions"> + <!-- eslint-disable @gitlab/vue-no-data-toggle --> <gl-button v-gl-tooltip :title="$options.i18n.moreActionsLabel" :aria-label="$options.i18n.moreActionsLabel" icon="ellipsis_v" - size="small" category="tertiary" class="note-action-button more-actions-toggle" data-toggle="dropdown" @click="closeTooltip" /> + <!-- eslint-enable @gitlab/vue-no-data-toggle --> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> <gl-dropdown-item v-if="canReportAsAbuse" :href="reportAbusePath"> {{ __('Report abuse to admin') }} diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue index 5ce03091504..0cd2afcf8a0 100644 --- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue +++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue @@ -22,7 +22,6 @@ export default { data-track-event="click_button" data-track-label="reply_comment_button" category="tertiary" - size="small" icon="comment" :title="$options.i18n.buttonText" :aria-label="$options.i18n.buttonText" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index a70bac94b71..4ce81219f11 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -362,36 +362,26 @@ export default { </template> </markdown-field> </comment-field-layout> - <div class="note-form-actions clearfix"> + <div class="note-form-actions"> <template v-if="showBatchCommentsActions"> <p v-if="showResolveDiscussionToggle"> <label> <template v-if="discussionResolved"> - <input - v-model="isUnresolving" - type="checkbox" - class="js-unresolve-checkbox" - data-qa-selector="unresolve_review_discussion_checkbox" - /> + <input v-model="isUnresolving" type="checkbox" class="js-unresolve-checkbox" /> {{ __('Unresolve thread') }} </template> <template v-else> - <input - v-model="isResolving" - type="checkbox" - class="js-resolve-checkbox" - data-qa-selector="resolve_review_discussion_checkbox" - /> + <input v-model="isResolving" type="checkbox" class="js-resolve-checkbox" /> {{ __('Resolve thread') }} </template> </label> </p> - <div class="gl-display-sm-flex gl-flex-wrap"> + <div class="gl-display-flex gl-flex-wrap gl-mb-n3"> <gl-button :disabled="isDisabled" category="primary" variant="confirm" - class="gl-mr-3" + class="gl-sm-mr-3 gl-mb-3" data-qa-selector="start_review_button" @click="handleAddToReview" > @@ -401,15 +391,15 @@ export default { <gl-button :disabled="isDisabled" category="secondary" - variant="default" + variant="confirm" data-qa-selector="comment_now_button" - class="gl-mr-3 js-comment-button" + class="gl-sm-mr-3 gl-mb-3 js-comment-button" @click="handleUpdate()" > {{ __('Add comment now') }} </gl-button> <gl-button - class="note-edit-cancel js-close-discussion-note-form" + class="note-edit-cancel gl-mb-3 js-close-discussion-note-form" category="secondary" variant="default" data-testid="cancelBatchCommentsEnabled" diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 1204d68159f..bdb85360be8 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -345,7 +345,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { // this is a temporary solution until we have confidentiality real-time updates if ( confidentialWidget.setConfidentiality && - message.some((m) => m.includes('confidential')) + message.some((m) => m.includes('Made this issue confidential')) ) { confidentialWidget.setConfidentiality(); } @@ -468,15 +468,6 @@ const getFetchDataParams = (state) => { return { endpoint, options }; }; -export const fetchData = ({ commit, state, getters, dispatch }) => { - const { endpoint, options } = getFetchDataParams(state); - - axios - .get(endpoint, options) - .then(({ data }) => pollSuccessCallBack(data, commit, state, getters, dispatch)) - .catch(() => Flash(__('Something went wrong while fetching latest comments.'))); -}; - export const poll = ({ commit, state, getters, dispatch }) => { eTagPoll = new Poll({ resource: { @@ -493,7 +484,7 @@ export const poll = ({ commit, state, getters, dispatch }) => { if (!Visibility.hidden()) { eTagPoll.makeDelayedRequest(2500); } else { - dispatch('fetchData'); + eTagPoll.makeRequest(); } Visibility.change(() => { diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 39f66063cfb..b04b1d28ffa 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -1,4 +1,6 @@ import { flattenDeep, clone } from 'lodash'; +import { statusBoxState } from '~/issuable/components/status_box.vue'; +import { isInMRPage } from '~/lib/utils/common_utils'; import * as constants from '../constants'; import { collapseSystemNotes } from './collapse_utils'; @@ -82,7 +84,8 @@ export const getBlockedByIssues = (state) => state.noteableData.blocked_by_issue export const userCanReply = (state) => Boolean(state.noteableData.current_user.can_create_note); -export const openState = (state) => state.noteableData.state; +export const openState = (state) => + isInMRPage() ? statusBoxState.state : state.noteableData.state; export const getUserData = (state) => state.userData || {}; diff --git a/app/assets/javascripts/packages/details/components/maven_installation.vue b/app/assets/javascripts/packages/details/components/maven_installation.vue index b9532cb7e72..6974de99344 100644 --- a/app/assets/javascripts/packages/details/components/maven_installation.vue +++ b/app/assets/javascripts/packages/details/components/maven_installation.vue @@ -28,10 +28,15 @@ export default { 'mavenSetupXml', 'gradleGroovyInstalCommand', 'gradleGroovyAddSourceCommand', + 'gradleKotlinInstalCommand', + 'gradleKotlinAddSourceCommand', ]), showMaven() { return this.instructionType === 'maven'; }, + showGroovy() { + return this.instructionType === 'groovy'; + }, }, i18n: { xmlText: s__( @@ -47,8 +52,9 @@ export default { trackingActions: { ...TrackingActions }, TrackingLabels, installOptions: [ - { value: 'maven', label: s__('PackageRegistry|Show Maven commands') }, - { value: 'groovy', label: s__('PackageRegistry|Show Gradle Groovy DSL commands') }, + { value: 'maven', label: s__('PackageRegistry|Maven XML') }, + { value: 'groovy', label: s__('PackageRegistry|Gradle Groovy DSL') }, + { value: 'kotlin', label: s__('PackageRegistry|Gradle Kotlin DSL') }, ], }; </script> @@ -107,7 +113,7 @@ export default { </template> </gl-sprintf> </template> - <template v-else> + <template v-else-if="showGroovy"> <code-instruction class="gl-mb-5" :label="s__('PackageRegistry|Gradle Groovy DSL install command')" @@ -125,5 +131,23 @@ export default { multiline /> </template> + <template v-else> + <code-instruction + class="gl-mb-5" + :label="s__('PackageRegistry|Gradle Kotlin DSL install command')" + :instruction="gradleKotlinInstalCommand" + :copy-text="s__('PackageRegistry|Copy Gradle Kotlin DSL install command')" + :tracking-action="$options.trackingActions.COPY_KOTLIN_INSTALL_COMMAND" + :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" + /> + <code-instruction + :label="s__('PackageRegistry|Add Gradle Kotlin DSL repository command')" + :instruction="gradleKotlinAddSourceCommand" + :copy-text="s__('PackageRegistry|Copy add Gradle Kotlin DSL repository command')" + :tracking-action="$options.trackingActions.COPY_KOTLIN_ADD_TO_SOURCE_COMMAND" + :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" + multiline + /> + </template> </div> </template> diff --git a/app/assets/javascripts/packages/details/components/npm_installation.vue b/app/assets/javascripts/packages/details/components/npm_installation.vue index 18f15e2c63e..6b0fcf5e4fe 100644 --- a/app/assets/javascripts/packages/details/components/npm_installation.vue +++ b/app/assets/javascripts/packages/details/components/npm_installation.vue @@ -14,6 +14,11 @@ export default { GlLink, GlSprintf, }, + data() { + return { + instructionType: 'npm', + }; + }, computed: { ...mapState(['npmHelpPath']), ...mapGetters(['npmInstallationCommand', 'npmSetupCommand']), @@ -29,6 +34,9 @@ export default { yarnSetupCommand() { return this.npmSetupCommand(NpmManager.YARN); }, + showNpm() { + return this.instructionType === 'npm'; + }, }, i18n: { helpText: s__( @@ -37,16 +45,23 @@ export default { }, trackingActions: { ...TrackingActions }, TrackingLabels, - installOptions: [{ value: 'npm', label: s__('PackageRegistry|Show NPM commands') }], + installOptions: [ + { value: 'npm', label: s__('PackageRegistry|Show NPM commands') }, + { value: 'yarn', label: s__('PackageRegistry|Show Yarn commands') }, + ], }; </script> <template> <div> - <installation-title package-type="npm" :options="$options.installOptions" /> + <installation-title + package-type="npm" + :options="$options.installOptions" + @change="instructionType = $event" + /> <code-instruction - :label="s__('PackageRegistry|npm command')" + v-if="showNpm" :instruction="npmCommand" :copy-text="s__('PackageRegistry|Copy npm command')" :tracking-action="$options.trackingActions.COPY_NPM_INSTALL_COMMAND" @@ -54,7 +69,7 @@ export default { /> <code-instruction - :label="s__('PackageRegistry|yarn command')" + v-else :instruction="yarnCommand" :copy-text="s__('PackageRegistry|Copy yarn command')" :tracking-action="$options.trackingActions.COPY_YARN_INSTALL_COMMAND" @@ -64,7 +79,7 @@ export default { <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> <code-instruction - :label="s__('PackageRegistry|npm command')" + v-if="showNpm" :instruction="npmSetup" :copy-text="s__('PackageRegistry|Copy npm setup command')" :tracking-action="$options.trackingActions.COPY_NPM_SETUP_COMMAND" @@ -72,7 +87,7 @@ export default { /> <code-instruction - :label="s__('PackageRegistry|yarn command')" + v-else :instruction="yarnSetupCommand" :copy-text="s__('PackageRegistry|Copy yarn setup command')" :tracking-action="$options.trackingActions.COPY_YARN_SETUP_COMMAND" diff --git a/app/assets/javascripts/packages/details/components/package_files.vue b/app/assets/javascripts/packages/details/components/package_files.vue index c5e929fe2a4..103d1f489bd 100644 --- a/app/assets/javascripts/packages/details/components/package_files.vue +++ b/app/assets/javascripts/packages/details/components/package_files.vue @@ -92,6 +92,7 @@ export default { <template #cell(commit)="{ item }"> <gl-link + v-if="item.pipeline && item.pipeline.project" :href="item.pipeline.project.commit_url" class="gl-text-gray-500" data-testid="commit-link" diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js index f0300ee29b4..cd34b1ad45a 100644 --- a/app/assets/javascripts/packages/details/constants.js +++ b/app/assets/javascripts/packages/details/constants.js @@ -38,6 +38,9 @@ export const TrackingActions = { COPY_GRADLE_INSTALL_COMMAND: 'copy_gradle_install_command', COPY_GRADLE_ADD_TO_SOURCE_COMMAND: 'copy_gradle_add_to_source_command', + + COPY_KOTLIN_INSTALL_COMMAND: 'copy_kotlin_install_command', + COPY_KOTLIN_ADD_TO_SOURCE_COMMAND: 'copy_kotlin_add_to_source_command', }; export const NpmManager = { diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js index fb9b7d61fd2..ae273e26d6a 100644 --- a/app/assets/javascripts/packages/details/store/getters.js +++ b/app/assets/javascripts/packages/details/store/getters.js @@ -126,4 +126,15 @@ export const gradleGroovyAddSourceCommand = ({ mavenPath }) => url '${mavenPath}' }`; +export const gradleKotlinInstalCommand = ({ packageEntity }) => { + const { + app_group: group = '', + app_name: name = '', + app_version: version = '', + } = packageEntity.maven_metadatum; + return `implementation("${group}:${name}:${version}")`; +}; + +export const gradleKotlinAddSourceCommand = ({ mavenPath }) => `maven("${mavenPath}")`; + export const groupExists = ({ groupListUrl }) => groupListUrl.length > 0; diff --git a/app/assets/javascripts/packages/list/components/package_search.vue b/app/assets/javascripts/packages/list/components/package_search.vue index 2e183b1b978..869a2c2f641 100644 --- a/app/assets/javascripts/packages/list/components/package_search.vue +++ b/app/assets/javascripts/packages/list/components/package_search.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapActions } from 'vuex'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; import { sortableFields } from '../utils'; @@ -14,7 +15,7 @@ export default { title: s__('PackageRegistry|Type'), unique: true, token: PackageTypeToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, }, ], components: { RegistrySearch, UrlSync }, diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js index b4fe3c70dea..d871c2e4d24 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages/list/constants.js @@ -93,3 +93,5 @@ export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry'); export const LIST_INTRO_TEXT = s__( 'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}', ); + +export const TERRAFORM_SEARCH_TYPE = Object.freeze({ value: { data: 'terraform_module' } }); diff --git a/app/assets/javascripts/packages/list/stores/actions.js b/app/assets/javascripts/packages/list/stores/actions.js index 195117b9ddb..8dfe3c82ab3 100644 --- a/app/assets/javascripts/packages/list/stores/actions.js +++ b/app/assets/javascripts/packages/list/stores/actions.js @@ -8,6 +8,7 @@ import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, MISSING_DELETE_PATH_ERROR, + TERRAFORM_SEARCH_TYPE, } from '../constants'; import { getNewPaginationPage } from '../utils'; import * as types from './mutation_types'; @@ -27,8 +28,9 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => { const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params; const { sort, orderBy } = state.sorting; - - const type = state.filter.find((f) => f.type === 'type'); + const type = state.config.forceTerraform + ? TERRAFORM_SEARCH_TYPE + : state.filter.find((f) => f.type === 'type'); const name = state.filter.find((f) => f.type === 'filtered-search-term'); const packageFilters = { package_type: type?.value?.data, package_name: name?.value?.data }; diff --git a/app/assets/javascripts/packages/list/stores/mutations.js b/app/assets/javascripts/packages/list/stores/mutations.js index 4ce13cfcb29..98165e581b0 100644 --- a/app/assets/javascripts/packages/list/stores/mutations.js +++ b/app/assets/javascripts/packages/list/stores/mutations.js @@ -4,9 +4,8 @@ import * as types from './mutation_types'; export default { [types.SET_INITIAL_STATE](state, config) { - const { comingSoonJson, ...rest } = config; state.config = { - ...rest, + ...config, isGroupPage: config.pageType === GROUP_PAGE_TYPE, }; }, diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue index 4de4c191e51..eee0e470c7b 100644 --- a/app/assets/javascripts/packages/shared/components/package_list_row.vue +++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue @@ -1,7 +1,9 @@ <script> import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; +import { s__ } from '~/locale'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { PACKAGE_ERROR_STATUS, PACKAGE_DEFAULT_STATUS } from '../constants'; import { getPackageTypeLabel } from '../utils'; import PackagePath from './package_path.vue'; import PackageTags from './package_tags.vue'; @@ -70,22 +72,45 @@ export default { hasProjectLink() { return Boolean(this.packageEntity.project_path); }, + showWarningIcon() { + return this.packageEntity.status === PACKAGE_ERROR_STATUS; + }, + disabledRow() { + return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS; + }, + disabledDeleteButton() { + return this.disabledRow || !this.packageEntity._links.delete_api_path; + }, + }, + i18n: { + erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'), }, }; </script> <template> - <list-item data-qa-selector="package_row"> + <list-item data-qa-selector="package_row" :disabled="disabledRow"> <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> <gl-link :href="packageLink" class="gl-text-body gl-min-w-0" data-qa-selector="package_link" + :disabled="disabledRow" > <gl-truncate :text="packageEntity.name" /> </gl-link> + <gl-button + v-if="showWarningIcon" + v-gl-tooltip="{ title: $options.i18n.erroredPackageText }" + class="gl-hover-bg-transparent!" + icon="warning" + category="tertiary" + data-testid="warning-icon" + :aria-label="__('Warning')" + /> + <package-tags v-if="packageEntity.tags && packageEntity.tags.length" class="gl-ml-3" @@ -109,7 +134,11 @@ export default { {{ packageType }} </component> - <package-path v-if="hasProjectLink" :path="packageEntity.project_path" /> + <package-path + v-if="hasProjectLink" + :path="packageEntity.project_path" + :disabled="disabledRow" + /> </div> </template> @@ -137,7 +166,7 @@ export default { variant="danger" :title="s__('PackageRegistry|Remove package')" :aria-label="s__('PackageRegistry|Remove package')" - :disabled="!packageEntity._links.delete_api_path" + :disabled="disabledDeleteButton" @click="$emit('packageToDelete', packageEntity)" /> </template> diff --git a/app/assets/javascripts/packages/shared/components/package_path.vue b/app/assets/javascripts/packages/shared/components/package_path.vue index 9afe06ab497..6fb001e5e92 100644 --- a/app/assets/javascripts/packages/shared/components/package_path.vue +++ b/app/assets/javascripts/packages/shared/components/package_path.vue @@ -16,6 +16,11 @@ export default { type: String, required: true, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, computed: { pathPieces() { @@ -45,7 +50,12 @@ export default { <div data-qa-selector="package-path" class="gl-display-flex gl-align-items-center"> <gl-icon data-testid="base-icon" name="project" class="gl-mx-3 gl-min-w-0" /> - <gl-link data-testid="root-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${rootLink}`"> + <gl-link + data-testid="root-link" + class="gl-text-gray-500 gl-min-w-0" + :href="`/${rootLink}`" + :disabled="disabled" + > {{ root }} </gl-link> @@ -63,7 +73,12 @@ export default { <gl-icon data-testid="ellipsis-chevron" name="chevron-right" class="gl-mx-2 gl-min-w-0" /> </template> - <gl-link data-testid="leaf-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${path}`"> + <gl-link + data-testid="leaf-link" + class="gl-text-gray-500 gl-min-w-0" + :href="`/${path}`" + :disabled="disabled" + > {{ leaf }} </gl-link> </template> diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js index f7de31c2c86..b3df542e0ae 100644 --- a/app/assets/javascripts/packages/shared/constants.js +++ b/app/assets/javascripts/packages/shared/constants.js @@ -26,3 +26,8 @@ export const TrackingCategories = { export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; export const DELETE_PACKAGE_ERROR_MESSAGE = __('Something went wrong while deleting the package.'); + +export const PACKAGE_ERROR_STATUS = 'error'; +export const PACKAGE_DEFAULT_STATUS = 'default'; +export const PACKAGE_HIDDEN_STATUS = 'hidden'; +export const PACKAGE_PROCESSING_STATUS = 'processing'; diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js index 88ee8a4200e..7e6e98f4fb5 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js @@ -9,7 +9,7 @@ Vue.use(Translate); export default () => { const el = document.getElementById('js-vue-packages-list'); const store = createStore(); - store.dispatch('setInitialState', el.dataset); + store.dispatch('setInitialState', { ...el.dataset, forceTerraform: true }); return new Vue({ el, diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue new file mode 100644 index 00000000000..d66a30e7e81 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue @@ -0,0 +1,118 @@ +<script> +import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { isEqual } from 'lodash'; + +import { + DUPLICATES_TOGGLE_LABEL, + DUPLICATES_ALLOWED_DISABLED, + DUPLICATES_ALLOWED_ENABLED, + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_SETTINGS_EXCEPTION_LEGEND, +} from '~/packages_and_registries/settings/group/constants'; + +export default { + name: 'DuplicatesSettings', + i18n: { + DUPLICATES_TOGGLE_LABEL, + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_SETTINGS_EXCEPTION_LEGEND, + }, + components: { + GlSprintf, + GlToggle, + GlFormGroup, + GlFormInput, + }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + duplicatesAllowed: { + type: Boolean, + default: false, + required: false, + }, + duplicateExceptionRegex: { + type: String, + default: '', + required: false, + }, + duplicateExceptionRegexError: { + type: String, + default: '', + required: false, + }, + modelNames: { + type: Object, + required: true, + validator(value) { + return isEqual(Object.keys(value), ['allowed', 'exception']); + }, + }, + toggleQaSelector: { + type: String, + required: false, + default: null, + }, + labelQaSelector: { + type: String, + required: false, + default: null, + }, + }, + computed: { + enabledButtonLabel() { + return this.duplicatesAllowed ? DUPLICATES_ALLOWED_ENABLED : DUPLICATES_ALLOWED_DISABLED; + }, + isExceptionRegexValid() { + return !this.duplicateExceptionRegexError; + }, + }, + methods: { + update(type, value) { + this.$emit('update', { [type]: value }); + }, + }, +}; +</script> + +<template> + <form> + <div class="gl-display-flex"> + <gl-toggle + :data-qa-selector="toggleQaSelector" + :label="$options.i18n.DUPLICATES_TOGGLE_LABEL" + label-position="hidden" + :value="duplicatesAllowed" + @change="update(modelNames.allowed, $event)" + /> + <div class="gl-ml-5"> + <div data-testid="toggle-label" :data-qa-selector="labelQaSelector"> + <gl-sprintf :message="enabledButtonLabel"> + <template #bold="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </div> + <gl-form-group + v-if="!duplicatesAllowed" + class="gl-mt-4" + :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE" + label-size="sm" + :state="isExceptionRegexValid" + :invalid-feedback="duplicateExceptionRegexError" + :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND" + label-for="maven-duplicated-settings-regex-input" + > + <gl-form-input + id="maven-duplicated-settings-regex-input" + :value="duplicateExceptionRegex" + @change="update(modelNames.exception, $event)" + /> + </gl-form-group> + </div> + </div> + </form> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue new file mode 100644 index 00000000000..e5f63fe8d0d --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue @@ -0,0 +1,26 @@ +<script> +import { s__ } from '~/locale'; +import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; + +export default { + name: 'GenericSettings', + components: { + SettingsTitles, + }, + i18n: { + title: s__('PackageRegistry|Generic'), + subTitle: s__('PackageRegistry|Settings for Generic packages'), + }, + modelNames: { + allowed: 'genericDuplicatesAllowed', + exception: 'genericDuplicateExceptionRegex', + }, +}; +</script> + +<template> + <div> + <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" /> + <slot :model-names="$options.modelNames"></slot> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue index 4f5c53ed4a3..01d4861f5c2 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue @@ -1,7 +1,8 @@ <script> import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; +import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; +import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; - import { PACKAGE_SETTINGS_HEADER, PACKAGE_SETTINGS_DESCRIPTION, @@ -30,6 +31,8 @@ export default { GlLink, SettingsBlock, MavenSettings, + GenericSettings, + DuplicatesSettings, }, inject: ['defaultExpanded', 'groupPath'], apollo: { @@ -128,13 +131,32 @@ export default { </span> </template> <template #default> - <maven-settings - :maven-duplicates-allowed="packageSettings.mavenDuplicatesAllowed" - :maven-duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex" - :maven-duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex" - :loading="isLoading" - @update="updateSettings" - /> + <maven-settings data-testid="maven-settings"> + <template #default="{ modelNames }"> + <duplicates-settings + :duplicates-allowed="packageSettings.mavenDuplicatesAllowed" + :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex" + :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex" + :model-names="modelNames" + :loading="isLoading" + toggle-qa-selector="allow_duplicates_toggle" + label-qa-selector="allow_duplicates_label" + @update="updateSettings" + /> + </template> + </maven-settings> + <generic-settings class="gl-mt-6" data-testid="generic-settings"> + <template #default="{ modelNames }"> + <duplicates-settings + :duplicates-allowed="packageSettings.genericDuplicatesAllowed" + :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex" + :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex" + :model-names="modelNames" + :loading="isLoading" + @update="updateSettings" + /> + </template> + </generic-settings> </template> </settings-block> </div> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue index faacabb44ce..a1cbd695f34 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue @@ -1,118 +1,26 @@ <script> -import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui'; - -import { - MAVEN_TOGGLE_LABEL, - MAVEN_TITLE, - MAVEN_SETTINGS_SUBTITLE, - MAVEN_DUPLICATES_ALLOWED_DISABLED, - MAVEN_DUPLICATES_ALLOWED_ENABLED, - MAVEN_SETTING_EXCEPTION_TITLE, - MAVEN_SETTINGS_EXCEPTION_LEGEND, - MAVEN_DUPLICATES_ALLOWED, - MAVEN_DUPLICATE_EXCEPTION_REGEX, -} from '~/packages_and_registries/settings/group/constants'; +import { s__ } from '~/locale'; +import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; export default { name: 'MavenSettings', - i18n: { - MAVEN_TOGGLE_LABEL, - MAVEN_TITLE, - MAVEN_SETTINGS_SUBTITLE, - MAVEN_SETTING_EXCEPTION_TITLE, - MAVEN_SETTINGS_EXCEPTION_LEGEND, - }, - modelNames: { - MAVEN_DUPLICATES_ALLOWED, - MAVEN_DUPLICATE_EXCEPTION_REGEX, - }, components: { - GlSprintf, - GlToggle, - GlFormGroup, - GlFormInput, - }, - props: { - loading: { - type: Boolean, - required: false, - default: false, - }, - mavenDuplicatesAllowed: { - type: Boolean, - default: false, - required: true, - }, - mavenDuplicateExceptionRegex: { - type: String, - default: '', - required: true, - }, - mavenDuplicateExceptionRegexError: { - type: String, - default: '', - required: false, - }, + SettingsTitles, }, - computed: { - enabledButtonLabel() { - return this.mavenDuplicatesAllowed - ? MAVEN_DUPLICATES_ALLOWED_ENABLED - : MAVEN_DUPLICATES_ALLOWED_DISABLED; - }, - isMavenDuplicateExceptionRegexValid() { - return !this.mavenDuplicateExceptionRegexError; - }, + i18n: { + title: s__('PackageRegistry|Maven'), + subTitle: s__('PackageRegistry|Settings for Maven packages'), }, - methods: { - update(type, value) { - this.$emit('update', { [type]: value }); - }, + modelNames: { + allowed: 'mavenDuplicatesAllowed', + exception: 'mavenDuplicateExceptionRegex', }, }; </script> <template> <div> - <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200"> - {{ $options.i18n.MAVEN_TITLE }} - </h5> - <p>{{ $options.i18n.MAVEN_SETTINGS_SUBTITLE }}</p> - <form> - <div class="gl-display-flex"> - <gl-toggle - data-qa-selector="allow_duplicates_toggle" - :label="$options.i18n.MAVEN_TOGGLE_LABEL" - label-position="hidden" - :value="mavenDuplicatesAllowed" - @change="update($options.modelNames.MAVEN_DUPLICATES_ALLOWED, $event)" - /> - <div class="gl-ml-5"> - <div data-testid="toggle-label" data-qa-selector="allow_duplicates_label"> - <gl-sprintf :message="enabledButtonLabel"> - <template #bold="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </div> - <gl-form-group - v-if="!mavenDuplicatesAllowed" - class="gl-mt-4" - :label="$options.i18n.MAVEN_SETTING_EXCEPTION_TITLE" - label-size="sm" - :state="isMavenDuplicateExceptionRegexValid" - :invalid-feedback="mavenDuplicateExceptionRegexError" - :description="$options.i18n.MAVEN_SETTINGS_EXCEPTION_LEGEND" - label-for="maven-duplicated-settings-regex-input" - > - <gl-form-input - id="maven-duplicated-settings-regex-input" - :value="mavenDuplicateExceptionRegex" - @change="update($options.modelNames.MAVEN_DUPLICATE_EXCEPTION_REGEX, $event)" - /> - </gl-form-group> - </div> - </div> - </form> + <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" /> + <slot :model-names="$options.modelNames"></slot> </div> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue new file mode 100644 index 00000000000..3f0ab7686e5 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue @@ -0,0 +1,25 @@ +<script> +export default { + name: 'SettingsTitle', + props: { + title: { + type: String, + required: true, + }, + subTitle: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200"> + {{ title }} + </h5> + <p>{{ subTitle }}</p> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js index d52a6a626f9..a2256c5c371 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -6,17 +6,15 @@ export const PACKAGE_SETTINGS_DESCRIPTION = s__( 'PackageRegistry|GitLab Packages allows organizations to utilize GitLab as a private repository for a variety of common package formats. %{linkStart}More Information%{linkEnd}', ); -export const MAVEN_TITLE = s__('PackageRegistry|Maven'); -export const MAVEN_SETTINGS_SUBTITLE = s__('PackageRegistry|Settings for Maven packages'); -export const MAVEN_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates'); -export const MAVEN_DUPLICATES_ALLOWED_DISABLED = s__( +export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates'); +export const DUPLICATES_ALLOWED_DISABLED = s__( 'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Packages with the same name and version are rejected.', ); -export const MAVEN_DUPLICATES_ALLOWED_ENABLED = s__( +export const DUPLICATES_ALLOWED_ENABLED = s__( 'PackageRegistry|%{boldStart}Allow duplicates%{boldEnd} - Packages with the same name and version are accepted.', ); -export const MAVEN_SETTING_EXCEPTION_TITLE = __('Exceptions'); -export const MAVEN_SETTINGS_EXCEPTION_LEGEND = s__( +export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions'); +export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__( 'PackageRegistry|Packages can be published if their name or version matches this regex', ); diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql index 1fc59bd3496..5c245ff9453 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql @@ -3,6 +3,8 @@ mutation updateNamespacePackageSettings($input: UpdateNamespacePackageSettingsIn packageSettings { mavenDuplicatesAllowed mavenDuplicateExceptionRegex + genericDuplicatesAllowed + genericDuplicateExceptionRegex } errors } diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql index 2011659887d..a1c01300893 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql @@ -3,6 +3,8 @@ query getGroupPackagesSettings($fullPath: ID!) { packageSettings { mavenDuplicatesAllowed mavenDuplicateExceptionRegex + genericDuplicatesAllowed + genericDuplicateExceptionRegex } } } diff --git a/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue index d75fb31fd98..d75fb31fd98 100644 --- a/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue diff --git a/app/assets/javascripts/registry/settings/components/expiration_input.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue index 42b7c7918a5..d6d85189792 100644 --- a/app/assets/javascripts/registry/settings/components/expiration_input.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue @@ -1,6 +1,9 @@ <script> import { GlFormGroup, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; -import { NAME_REGEX_LENGTH, TEXT_AREA_INVALID_FEEDBACK } from '../constants'; +import { + NAME_REGEX_LENGTH, + TEXT_AREA_INVALID_FEEDBACK, +} from '~/packages_and_registries/settings/project/constants'; export default { components: { diff --git a/app/assets/javascripts/registry/settings/components/expiration_run_text.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_run_text.vue index fd9ca6a54c5..0c595fa79b4 100644 --- a/app/assets/javascripts/registry/settings/components/expiration_run_text.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_run_text.vue @@ -1,6 +1,9 @@ <script> import { GlFormGroup, GlFormInput } from '@gitlab/ui'; -import { NEXT_CLEANUP_LABEL, NOT_SCHEDULED_POLICY_TEXT } from '~/registry/settings/constants'; +import { + NEXT_CLEANUP_LABEL, + NOT_SCHEDULED_POLICY_TEXT, +} from '~/packages_and_registries/settings/project/constants'; export default { components: { diff --git a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue index 6aa78c69ba9..7a9ea7c0bf7 100644 --- a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue @@ -1,7 +1,10 @@ <script> import { GlFormGroup, GlToggle, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; -import { ENABLED_TOGGLE_DESCRIPTION, DISABLED_TOGGLE_DESCRIPTION } from '../constants'; +import { + ENABLED_TOGGLE_DESCRIPTION, + DISABLED_TOGGLE_DESCRIPTION, +} from '~/packages_and_registries/settings/project/constants'; export default { i18n: { diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue index 480590ec71e..edbe9441e57 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue @@ -7,8 +7,8 @@ import { UNAVAILABLE_FEATURE_INTRO_TEXT, UNAVAILABLE_USER_FEATURE_TEXT, UNAVAILABLE_ADMIN_FEATURE_TEXT, -} from '../constants'; -import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.query.graphql'; +} from '~/packages_and_registries/settings/project/constants'; +import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; import SettingsForm from './settings_form.vue'; diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue index 1360e09a75d..41be70a3ad5 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue @@ -17,10 +17,10 @@ import { NAME_REGEX_DESCRIPTION, CADENCE_LABEL, EXPIRATION_POLICY_FOOTER_NOTE, -} from '~/registry/settings/constants'; -import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql'; -import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update'; -import { formOptionsGenerator } from '~/registry/settings/utils'; +} from '~/packages_and_registries/settings/project/constants'; +import updateContainerExpirationPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql'; +import { updateContainerExpirationPolicy } from '~/packages_and_registries/settings/project/graphql/utils/cache_update'; +import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils'; import Tracking from '~/tracking'; import ExpirationDropdown from './expiration_dropdown.vue'; import ExpirationInput from './expiration_input.vue'; diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index 165c4aae3cb..165c4aae3cb 100644 --- a/app/assets/javascripts/registry/settings/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/container_expiration_policy.fragment.graphql index 1d6c89133af..1d6c89133af 100644 --- a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/container_expiration_policy.fragment.graphql diff --git a/app/assets/javascripts/registry/settings/graphql/index.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js index 16152eb81f6..16152eb81f6 100644 --- a/app/assets/javascripts/registry/settings/graphql/index.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js diff --git a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql index c40cd115ab0..c40cd115ab0 100644 --- a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql diff --git a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql index c171be0ad07..c171be0ad07 100644 --- a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.query.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js index c4b2af13862..c4b2af13862 100644 --- a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js index 65af6f846aa..65af6f846aa 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js diff --git a/app/assets/javascripts/registry/settings/utils.js b/app/assets/javascripts/packages_and_registries/settings/project/utils.js index 4a2d7c7d466..4a2d7c7d466 100644 --- a/app/assets/javascripts/registry/settings/utils.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/utils.js diff --git a/app/assets/javascripts/pages/admin/dev_ops_report/index.js b/app/assets/javascripts/pages/admin/dev_ops_report/index.js index cf06ee2c22a..d6fa1be29b0 100644 --- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js +++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js @@ -1,3 +1,5 @@ -import initDevOpsScoreEmptyState from '~/analytics/devops_report/devops_score_empty_state'; +import initDevOpsScore from '~/analytics/devops_report/devops_score'; +import initDevOpsScoreDisabledUsagePing from '~/analytics/devops_report/devops_score_disabled_usage_ping'; -initDevOpsScoreEmptyState(); +initDevOpsScoreDisabledUsagePing(); +initDevOpsScore(); diff --git a/app/assets/javascripts/pages/admin/labels/index/index.js b/app/assets/javascripts/pages/admin/labels/index/index.js index e5ab5d43bbf..17ee7c03ed6 100644 --- a/app/assets/javascripts/pages/admin/labels/index/index.js +++ b/app/assets/javascripts/pages/admin/labels/index/index.js @@ -1,3 +1,21 @@ -import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; +document.addEventListener('DOMContentLoaded', () => { + const pagination = document.querySelector('.labels .gl-pagination'); + const emptyState = document.querySelector('.labels .nothing-here-block.hidden'); -document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior); + function removeLabelSuccessCallback() { + this.closest('li').classList.add('gl-display-none!'); + + const labelsCount = document.querySelectorAll( + 'ul.manage-labels-list li:not(.gl-display-none\\!)', + ).length; + + // display the empty state if there are no more labels + if (labelsCount < 1 && !pagination && emptyState) { + emptyState.classList.remove('hidden'); + } + } + + document.querySelectorAll('.js-remove-label').forEach((row) => { + row.addEventListener('ajax:success', removeLabelSuccessCallback); + }); +}); diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue index 20407334b3f..a3b78da6ef5 100644 --- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue +++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue @@ -1,6 +1,8 @@ <script> import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { s__, sprintf } from '~/locale'; +import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; export default { components: { @@ -8,6 +10,7 @@ export default { GlButton, GlFormInput, GlSprintf, + OncallSchedulesList, }, props: { title: { @@ -42,6 +45,11 @@ export default { type: String, required: true, }, + oncallSchedules: { + type: String, + required: false, + default: '[]', + }, }, data() { return { @@ -58,6 +66,14 @@ export default { canSubmit() { return this.enteredUsername === this.username; }, + schedules() { + try { + return JSON.parse(this.oncallSchedules); + } catch (e) { + Sentry.captureException(e); + } + return []; + }, }, methods: { show() { @@ -96,6 +112,8 @@ export default { </gl-sprintf> </p> + <oncall-schedules-list v-if="schedules.length" :schedules="schedules" /> + <p> <gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')"> <template #username> diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js index b1079c3b068..9a8b0c9990f 100644 --- a/app/assets/javascripts/pages/admin/users/index.js +++ b/app/assets/javascripts/pages/admin/users/index.js @@ -1,7 +1,6 @@ import Vue from 'vue'; -import { initAdminUsersApp, initCohortsEmptyState } from '~/admin/users'; -import initTabs from '~/admin/users/tabs'; +import { initAdminUsersApp } from '~/admin/users'; import initConfirmModal from '~/confirm_modal'; import csrf from '~/lib/utils/csrf'; import Translate from '~/vue_shared/translate'; @@ -62,6 +61,4 @@ document.addEventListener('DOMContentLoaded', () => { }); initConfirmModal(); - initCohortsEmptyState(); - initTabs(); }); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index b60607e8857..76db578f6f9 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,6 +1,6 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; -import initIssuablesList from '~/issues_list'; +import { mountIssuablesListApp } from '~/issues_list'; import initManualOrdering from '~/manual_ordering'; import { FILTERED_SEARCH } from '~/pages/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; @@ -12,8 +12,6 @@ IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); IssuableFilteredSearchTokenKeys.removeTokensForKeys('release'); issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); -initIssuablesList(); - initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, isGroupDecendent: true, @@ -22,3 +20,7 @@ initFilteredSearch({ }); projectSelect(); initManualOrdering(); + +if (gon.features?.vueIssuablesList) { + mountIssuablesListApp(); +} diff --git a/app/assets/javascripts/pages/groups/milestones/edit/index.js b/app/assets/javascripts/pages/groups/milestones/edit/index.js index af0264c7992..4f8514a9a1d 100644 --- a/app/assets/javascripts/pages/groups/milestones/edit/index.js +++ b/app/assets/javascripts/pages/groups/milestones/edit/index.js @@ -1,3 +1,3 @@ -import initForm from '../../../../shared/milestones/form'; +import initForm from '~/shared/milestones/form'; -initForm(false); +initForm(); diff --git a/app/assets/javascripts/pages/groups/milestones/new/index.js b/app/assets/javascripts/pages/groups/milestones/new/index.js index af0264c7992..4f8514a9a1d 100644 --- a/app/assets/javascripts/pages/groups/milestones/new/index.js +++ b/app/assets/javascripts/pages/groups/milestones/new/index.js @@ -1,3 +1,3 @@ -import initForm from '../../../../shared/milestones/form'; +import initForm from '~/shared/milestones/form'; -initForm(false); +initForm(); diff --git a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js b/app/assets/javascripts/pages/groups/settings/packages_and_registries/show/index.js index 3b922622d2c..3b922622d2c 100644 --- a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js +++ b/app/assets/javascripts/pages/groups/settings/packages_and_registries/show/index.js diff --git a/app/assets/javascripts/pages/groups/settings/repository/show/index.js b/app/assets/javascripts/pages/groups/settings/repository/show/index.js index 92405f205cb..f048955dadf 100644 --- a/app/assets/javascripts/pages/groups/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js @@ -1,7 +1,8 @@ -import DueDateSelectors from '~/due_date_select'; +import initDatePicker from '~/behaviors/date_picker'; import initSettingsPanels from '~/settings_panels'; // Initialize expandable settings panels initSettingsPanels(); -new DueDateSelectors(); // eslint-disable-line no-new +// Used for deploy tokens "expires at" field +initDatePicker(); diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index 9e75985c130..2aec0617b5a 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -3,6 +3,7 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants'; import initInviteMembersBanner from '~/groups/init_invite_members_banner'; +import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import { getPagePath, getDashPath } from '~/lib/utils/common_utils'; import initNotificationsDropdown from '~/notifications'; import ProjectsList from '~/projects_list'; @@ -24,4 +25,5 @@ export default function initGroupDetails(actionName = 'show') { new ProjectsList(); initInviteMembersBanner(); + initInviteMembersModal(); } diff --git a/app/assets/javascripts/pages/help/show/index.js b/app/assets/javascripts/pages/help/show/index.js deleted file mode 100644 index ec426a850b6..00000000000 --- a/app/assets/javascripts/pages/help/show/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initHelp from '~/help/help'; - -document.addEventListener('DOMContentLoaded', initHelp); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index fc2702b8c37..8a8ce70e998 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -1,25 +1,35 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; import BlobViewer from '~/blob/viewer/index'; import GpgBadges from '~/gpg_badges'; +import createDefaultClient from '~/lib/graphql'; import initBlob from '~/pages/projects/init_blob'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import '~/sourcegraph/load'; +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + const viewBlobEl = document.querySelector('#js-view-blob-app'); if (viewBlobEl) { - const { blobPath } = viewBlobEl.dataset; + const { blobPath, projectPath } = viewBlobEl.dataset; // eslint-disable-next-line no-new new Vue({ el: viewBlobEl, + apolloProvider, render(createElement) { return createElement(BlobContentViewer, { props: { path: blobPath, + projectPath, }, }); }, diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 90a663802d2..d75c3cc6b8b 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -33,7 +33,7 @@ if (filesContainer.length) { axios .get(batchPath) .then(({ data }) => { - filesContainer.html($(data.html)); + filesContainer.html($(data)); syntaxHighlight(filesContainer); handleLocationHash(); new Diff(); diff --git a/app/assets/javascripts/pages/projects/compare/index.js b/app/assets/javascripts/pages/projects/compare/index.js deleted file mode 100644 index 768da8fb236..00000000000 --- a/app/assets/javascripts/pages/projects/compare/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initCompareAutocomplete from '~/compare_autocomplete'; - -document.addEventListener('DOMContentLoaded', () => initCompareAutocomplete()); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 288d6711682..07cc0ce46bc 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -20,6 +20,7 @@ import axios from '~/lib/utils/axios_utils'; import csrf from '~/lib/utils/csrf'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; +import validation from '~/vue_shared/directives/validation'; const PRIVATE_VISIBILITY = 'private'; const INTERNAL_VISIBILITY = 'internal'; @@ -31,6 +32,13 @@ const ALLOWED_VISIBILITY = { public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY], }; +const initFormField = ({ value, required = true, skipValidation = false }) => ({ + value, + required, + state: skipValidation ? true : null, + feedback: null, +}); + export default { components: { GlForm, @@ -46,6 +54,9 @@ export default { GlFormRadioGroup, GlFormSelect, }, + directives: { + validation: validation(), + }, inject: { newGroupPath: { default: '', @@ -77,7 +88,8 @@ export default { }, projectDescription: { type: String, - required: true, + required: false, + default: '', }, projectVisibility: { type: String, @@ -85,16 +97,30 @@ export default { }, }, data() { + const form = { + state: false, + showValidation: false, + fields: { + namespace: initFormField({ + value: null, + }), + name: initFormField({ value: this.projectName }), + slug: initFormField({ value: this.projectPath }), + description: initFormField({ + value: this.projectDescription, + required: false, + skipValidation: true, + }), + visibility: initFormField({ + value: this.projectVisibility, + skipValidation: true, + }), + }, + }; return { isSaving: false, namespaces: [], - selectedNamespace: {}, - fork: { - name: this.projectName, - slug: this.projectPath, - description: this.projectDescription, - visibility: this.projectVisibility, - }, + form, }; }, computed: { @@ -106,7 +132,7 @@ export default { }, namespaceAllowedVisibility() { return ( - ALLOWED_VISIBILITY[this.selectedNamespace.visibility] || + ALLOWED_VISIBILITY[this.form.fields.namespace.value?.visibility] || ALLOWED_VISIBILITY[PUBLIC_VISIBILITY] ); }, @@ -139,16 +165,17 @@ export default { }, }, watch: { - selectedNamespace(newVal) { + // eslint-disable-next-line func-names + 'form.fields.namespace.value': function (newVal) { const { visibility } = newVal; if (this.projectAllowedVisibility.includes(visibility)) { - this.fork.visibility = visibility; + this.form.fields.visibility.value = visibility; } }, // eslint-disable-next-line func-names - 'fork.name': function (newVal) { - this.fork.slug = kebabCase(newVal); + 'form.fields.name.value': function (newVal) { + this.form.fields.slug.value = kebabCase(newVal); }, }, mounted() { @@ -166,19 +193,25 @@ export default { ); }, async onSubmit() { + this.form.showValidation = true; + + if (!this.form.state) { + return; + } + this.isSaving = true; + this.form.showValidation = false; const { projectId } = this; - const { name, slug, description, visibility } = this.fork; - const { id: namespaceId } = this.selectedNamespace; + const { name, slug, description, visibility, namespace } = this.form.fields; const postParams = { id: projectId, - name, - namespace_id: namespaceId, - path: slug, - description, - visibility, + name: name.value, + namespace_id: namespace.value.id, + path: slug.value, + description: description.value, + visibility: visibility.value, }; const forkProjectPath = `/api/:version/projects/:id/fork`; @@ -198,16 +231,34 @@ export default { </script> <template> - <gl-form method="POST" @submit.prevent="onSubmit"> + <gl-form novalidate method="POST" @submit.prevent="onSubmit"> <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> - <gl-form-group label="Project name" label-for="fork-name"> - <gl-form-input id="fork-name" v-model="fork.name" data-testid="fork-name-input" required /> + <gl-form-group + :label="__('Project name')" + label-for="fork-name" + :invalid-feedback="form.fields.name.feedback" + > + <gl-form-input + id="fork-name" + v-model="form.fields.name.value" + v-validation:[form.showValidation] + name="name" + data-testid="fork-name-input" + :state="form.fields.name.state" + required + /> </gl-form-group> <div class="gl-md-display-flex"> <div class="gl-flex-basis-half"> - <gl-form-group label="Project URL" label-for="fork-url" class="gl-md-mr-3"> + <gl-form-group + :label="__('Project URL')" + label-for="fork-url" + class="gl-md-mr-3" + :state="form.fields.namespace.state" + :invalid-feedback="s__('ForkProject|Please select a namespace')" + > <gl-form-input-group> <template #prepend> <gl-input-group-text> @@ -216,9 +267,12 @@ export default { </template> <gl-form-select id="fork-url" - v-model="selectedNamespace" + v-model="form.fields.namespace.value" + v-validation:[form.showValidation] + name="namespace" data-testid="fork-url-input" data-qa-selector="fork_namespace_dropdown" + :state="form.fields.namespace.state" required > <template slot="first"> @@ -232,11 +286,19 @@ export default { </gl-form-group> </div> <div class="gl-flex-basis-half"> - <gl-form-group label="Project slug" label-for="fork-slug" class="gl-md-ml-3"> + <gl-form-group + :label="__('Project slug')" + label-for="fork-slug" + class="gl-md-ml-3" + :invalid-feedback="form.fields.slug.feedback" + > <gl-form-input id="fork-slug" - v-model="fork.slug" + v-model="form.fields.slug.value" + v-validation:[form.showValidation] data-testid="fork-slug-input" + name="slug" + :state="form.fields.slug.state" required /> </gl-form-group> @@ -250,11 +312,13 @@ export default { </gl-link> </p> - <gl-form-group label="Project description (optional)" label-for="fork-description"> + <gl-form-group :label="__('Project description (optional)')" label-for="fork-description"> <gl-form-textarea id="fork-description" - v-model="fork.description" + v-model="form.fields.description.value" data-testid="fork-description-textarea" + name="description" + :state="form.fields.description.state" /> </gl-form-group> @@ -266,8 +330,9 @@ export default { </gl-link> </label> <gl-form-radio-group - v-model="fork.visibility" + v-model="form.fields.visibility.value" data-testid="fork-visibility-radio-group" + name="visibility" required > <gl-form-radio @@ -291,6 +356,7 @@ export default { type="submit" category="primary" variant="confirm" + class="js-no-auto-disable" data-testid="submit-button" data-qa-selector="fork_project_button" :loading="isSaving" diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index 85489ae8687..8cd703133f5 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -1,36 +1,38 @@ -/* eslint-disable no-new */ - import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons'; import initIssuableByEmail from '~/issuable/init_issuable_by_email'; import IssuableIndex from '~/issuable_index'; -import initIssuablesList, { initIssuesListApp } from '~/issues_list'; +import { mountIssuablesListApp, mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list'; import initManualOrdering from '~/manual_ordering'; import { FILTERED_SEARCH } from '~/pages/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import UsersSelect from '~/users_select'; -IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); - -initFilteredSearch({ - page: FILTERED_SEARCH.ISSUES, - filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, - useDefaultState: true, -}); - if (gon.features?.vueIssuesList) { - new IssuableIndex(); + mountIssuesListApp(); } else { - new IssuableIndex(ISSUABLE_INDEX.ISSUE); + IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); + + initFilteredSearch({ + page: FILTERED_SEARCH.ISSUES, + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, + }); + + new IssuableIndex(ISSUABLE_INDEX.ISSUE); // eslint-disable-line no-new + new UsersSelect(); // eslint-disable-line no-new + + initCsvImportExportButtons(); + initIssuableByEmail(); + initManualOrdering(); + + if (gon.features?.vueIssuablesList) { + mountIssuablesListApp(); + } } -new ShortcutsNavigation(); -new UsersSelect(); +new ShortcutsNavigation(); // eslint-disable-line no-new -initManualOrdering(); -initIssuablesList(); -initIssuableByEmail(); -initCsvImportExportButtons(); -initIssuesListApp(); +mountJiraIssuesListApp(); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js index 5be9f6117dc..d906c579697 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,4 +1,4 @@ -import initIssuablesList from '~/issues_list'; +import { mountIssuablesListApp } from '~/issues_list'; import FilteredSearchServiceDesk from './filtered_search'; const supportBotData = JSON.parse( @@ -11,5 +11,5 @@ if (document.querySelector('.filtered-search')) { } if (gon.features?.vueIssuablesList) { - initIssuablesList(); + mountIssuablesListApp(); } diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 2b679a83eac..3143ff5adac 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -1,8 +1,6 @@ import loadAwardsHandler from '~/awards_handler'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import initIssuableSidebar from '~/init_issuable_sidebar'; -import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; -import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { IssuableType } from '~/issuable_show/constants'; @@ -58,7 +56,5 @@ export default function initShowIssue() { } else { loadAwardsHandler(); } - initInviteMemberModal(); - initInviteMemberTrigger(); } } diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue index ef9e13f7ccf..51980b2d971 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue @@ -18,9 +18,13 @@ export default { required: true, type: Object, }, + sections: { + required: true, + type: Object, + }, }, maxValue: Object.keys(ACTION_LABELS).length, - sections: Object.keys(ACTION_SECTIONS), + actionSections: Object.keys(ACTION_SECTIONS), computed: { progressValue() { return Object.values(this.actions).filter((a) => a.completed).length; @@ -38,6 +42,9 @@ export default { ); return actions; }, + svgFor(section) { + return this.sections[section].svg; + }, }, }; </script> @@ -59,8 +66,12 @@ export default { <gl-progress-bar :value="progressValue" :max="$options.maxValue" /> </div> <div class="row row-cols-1 row-cols-md-3 gl-mt-5"> - <div v-for="section in $options.sections" :key="section" class="col gl-mb-6"> - <learn-gitlab-section-card :section="section" :actions="actionsFor(section)" /> + <div v-for="section in $options.actionSections" :key="section" class="col gl-mb-6"> + <learn-gitlab-section-card + :section="section" + :svg="svgFor(section)" + :actions="actionsFor(section)" + /> </div> </div> </div> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue index 6cd3bbc359b..ad6dfbf41ca 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue @@ -64,7 +64,15 @@ export default { <img :src="svg" :alt="actionLabel" /> <h6>{{ title }}</h6> <p class="gl-font-sm gl-text-gray-700">{{ description }}</p> - <gl-link :href="url" target="_blank">{{ actionLabel }}</gl-link> + <gl-link + :href="url" + target="_blank" + rel="noopener noreferrer" + data-track-action="click_link" + :data-track-label="actionLabel" + data-track-property="Growth::Activation::Experiment::LearnGitLabB" + >{{ actionLabel }}</gl-link + > </div> </gl-card> </template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue index db694a66afd..6a196687a76 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue @@ -1,6 +1,5 @@ <script> import { GlCard } from '@gitlab/ui'; -import { imagePath } from '~/lib/utils/common_utils'; import { ACTION_LABELS, ACTION_SECTIONS } from '../constants'; import LearnGitlabSectionLink from './learn_gitlab_section_link.vue'; @@ -16,6 +15,10 @@ export default { required: true, type: String, }, + svg: { + required: true, + type: String, + }, actions: { required: true, type: Object, @@ -28,17 +31,12 @@ export default { ); }, }, - methods: { - svg(section) { - return imagePath(`learn_gitlab/section_${section}.svg`); - }, - }, }; </script> <template> <gl-card class="gl-pt-0 learn-gitlab-section-card"> <div class="learn-gitlab-section-card-header"> - <img :src="svg(section)" /> + <img :src="svg" /> <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n[section].title }}</h2> <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n[section].description }}</p> </div> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue index 6f51c7372fd..3d31ac6c267 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue @@ -34,7 +34,15 @@ export default { {{ $options.i18n.ACTION_LABELS[action].title }} </span> <span v-else> - <gl-link :href="value.url">{{ $options.i18n.ACTION_LABELS[action].title }}</gl-link> + <gl-link + target="_blank" + :href="value.url" + data-track-action="click_link" + :data-track-label="$options.i18n.ACTION_LABELS[action].title" + data-track-property="Growth::Conversion::Experiment::LearnGitLabA" + > + {{ $options.i18n.ACTION_LABELS[action].title }} + </gl-link> </span> <span v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only"> - {{ $options.i18n.trialOnly }} diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js index c4dec89b984..ac7c94bdd9e 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import trackLearnGitlab from '~/learn_gitlab/track_learn_gitlab'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import LearnGitlabA from '../components/learn_gitlab_a.vue'; import LearnGitlabB from '../components/learn_gitlab_b.vue'; @@ -11,13 +12,18 @@ function initLearnGitlab() { } const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions)); + const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections)); const { learnGitlabA } = gon.experiments; + trackLearnGitlab(learnGitlabA); + return new Vue({ el, render(createElement) { - return createElement(learnGitlabA ? LearnGitlabA : LearnGitlabB, { props: { actions } }); + return createElement(learnGitlabA ? LearnGitlabA : LearnGitlabB, { + props: { actions, sections }, + }); }, }); } diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js index 1a0fa6e544e..8d152ec4ba6 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js @@ -1,7 +1,7 @@ import $ from 'jquery'; -import initCompareAutocomplete from '~/compare_autocomplete'; import axios from '~/lib/utils/axios_utils'; import { localTimeAgo } from '~/lib/utils/datetime_utility'; +import initCompareAutocomplete from './compare_autocomplete'; import initTargetProjectDropdown from './target_project_dropdown'; const updateCommitList = (url, $loadingIndicator, $commitList, params) => { diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js index 314e4e911ee..68ab7021cf3 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js @@ -2,11 +2,11 @@ import $ from 'jquery'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { deprecatedCreateFlash as flash } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; import { fixTitle } from '~/tooltips'; -import { deprecatedCreateFlash as flash } from './flash'; -import axios from './lib/utils/axios_utils'; -import { capitalizeFirstCharacter } from './lib/utils/text_utility'; -import { __ } from './locale'; export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) { $('.js-compare-dropdown').each(function () { diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index a5118e3529a..6cd3202815b 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -1,16 +1,17 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import loadAwardsHandler from '~/awards_handler'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import initIssuableSidebar from '~/init_issuable_sidebar'; -import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; -import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; +import StatusBox from '~/issuable/components/status_box.vue'; +import createDefaultClient from '~/lib/graphql'; import { handleLocationHash } from '~/lib/utils/common_utils'; -import StatusBox from '~/merge_request/components/status_box.vue'; import initSourcegraph from '~/sourcegraph'; import ZenMode from '~/zen_mode'; +import getStateQuery from './queries/get_state.query.graphql'; export default function initMergeRequestShow() { const awardEmojiEl = document.getElementById('js-vue-awards-block'); @@ -28,15 +29,20 @@ export default function initMergeRequestShow() { } else { loadAwardsHandler(); } - initInviteMemberModal(); - initInviteMemberTrigger(); initInviteMembersModal(); initInviteMembersTrigger(); const el = document.querySelector('.js-mr-status-box'); + const apolloProvider = new VueApollo({ defaultClient: createDefaultClient() }); // eslint-disable-next-line no-new new Vue({ el, + apolloProvider, + provide: { + query: getStateQuery, + projectPath: el.dataset.projectPath, + iid: el.dataset.iid, + }, render(h) { return h(StatusBox, { props: { diff --git a/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql b/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql new file mode 100644 index 00000000000..b5a82b9428e --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql @@ -0,0 +1,7 @@ +query getMergeRequestState($projectPath: ID!, $iid: String!) { + workspace: project(fullPath: $projectPath) { + issuable: mergeRequest(iid: $iid) { + state + } + } +} diff --git a/app/assets/javascripts/pages/projects/milestones/new/index.js b/app/assets/javascripts/pages/projects/milestones/new/index.js index 364b0d95d9c..4f8514a9a1d 100644 --- a/app/assets/javascripts/pages/projects/milestones/new/index.js +++ b/app/assets/javascripts/pages/projects/milestones/new/index.js @@ -1,3 +1,3 @@ -import initForm from '../../../../shared/milestones/form'; +import initForm from '~/shared/milestones/form'; initForm(); diff --git a/app/assets/javascripts/pages/projects/new/components/app.vue b/app/assets/javascripts/pages/projects/new/components/app.vue new file mode 100644 index 00000000000..60a4fbc3e6b --- /dev/null +++ b/app/assets/javascripts/pages/projects/new/components/app.vue @@ -0,0 +1,148 @@ +<script> +import createFromTemplateIllustration from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg'; +import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg'; +import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg'; +import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg'; +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { experiment } from '~/experimentation/utils'; +import { s__ } from '~/locale'; +import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; +import NewProjectPushTipPopover from './new_project_push_tip_popover.vue'; + +const NEW_REPO_EXPERIMENT = 'new_repo'; +const CI_CD_PANEL = 'cicd_for_external_repo'; +const PANELS = [ + { + key: 'blank', + name: 'blank_project', + selector: '#blank-project-pane', + title: s__('ProjectsNew|Create blank project'), + description: s__( + 'ProjectsNew|Create a blank project to house your files, plan your work, and collaborate on code, among other things.', + ), + illustration: blankProjectIllustration, + }, + { + key: 'template', + name: 'create_from_template', + selector: '#create-from-template-pane', + title: s__('ProjectsNew|Create from template'), + description: s__( + 'ProjectsNew|Create a project pre-populated with the necessary files to get you started quickly.', + ), + illustration: createFromTemplateIllustration, + }, + { + key: 'import', + name: 'import_project', + selector: '#import-project-pane', + title: s__('ProjectsNew|Import project'), + description: s__( + 'ProjectsNew|Migrate your data from an external source like GitHub, Bitbucket, or another instance of GitLab.', + ), + illustration: importProjectIllustration, + }, + { + key: 'ci', + name: CI_CD_PANEL, + selector: '#ci-cd-project-pane', + title: s__('ProjectsNew|Run CI/CD for external repository'), + description: s__('ProjectsNew|Connect your external repository to GitLab CI/CD.'), + illustration: ciCdProjectIllustration, + }, +]; + +export default { + components: { + NewNamespacePage, + NewProjectPushTipPopover, + }, + directives: { + SafeHtml, + }, + props: { + hasErrors: { + type: Boolean, + required: false, + default: false, + }, + isCiCdAvailable: { + type: Boolean, + required: false, + default: false, + }, + newProjectGuidelines: { + type: String, + required: false, + default: '', + }, + }, + + computed: { + decoratedPanels() { + const PANEL_TITLES = experiment(NEW_REPO_EXPERIMENT, { + use: () => ({ + blank: s__('ProjectsNew|Create blank project'), + import: s__('ProjectsNew|Import project'), + }), + try: () => ({ + blank: s__('ProjectsNew|Create blank project/repository'), + import: s__('ProjectsNew|Import project/repository'), + }), + }); + + return PANELS.map(({ key, title, ...el }) => ({ + ...el, + title: PANEL_TITLES[key] ?? title, + })); + }, + + availablePanels() { + return this.isCiCdAvailable + ? this.decoratedPanels + : this.decoratedPanels.filter((p) => p.name !== CI_CD_PANEL); + }, + }, + + methods: { + resetProjectErrors() { + const errorsContainer = document.querySelector('.project-edit-errors'); + if (errorsContainer) { + errorsContainer.innerHTML = ''; + } + }, + }, + EXPERIMENT: NEW_REPO_EXPERIMENT, +}; +</script> + +<template> + <new-namespace-page + :initial-breadcrumb="s__('New project')" + :panels="availablePanels" + :jump-to-last-persisted-panel="hasErrors" + :title="s__('ProjectsNew|Create new project')" + :experiment="$options.EXPERIMENT" + persistence-key="new_project_last_active_tab" + @panel-change="resetProjectErrors" + > + <template #extra-description> + <div + v-if="newProjectGuidelines" + id="new-project-guideline" + v-safe-html="newProjectGuidelines" + ></div> + </template> + <template #welcome-footer> + <div class="gl-pt-5 gl-text-center"> + <p> + {{ __('You can also create a project from the command line.') }} + <a ref="clipTip" href="#" @click.prevent> + {{ __('Show command') }} + </a> + <new-project-push-tip-popover :target="() => $refs.clipTip" /> + </p> + </div> + </template> + </new-namespace-page> +</template> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue b/app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue index e42d9154866..e42d9154866 100644 --- a/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue +++ b/app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index e10e2872dce..f469c56e808 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -1,28 +1,44 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; -import { __ } from '~/locale'; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import initProjectVisibilitySelector from '../../../project_visibility'; import initProjectNew from '../../../projects/project_new'; +import NewProjectCreationApp from './components/app.vue'; initProjectVisibilitySelector(); initProjectNew.bindEvents(); -import( - /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation' -) - .then((m) => { - const el = document.querySelector('.js-experiment-new-project-creation'); +function initNewProjectCreation(el) { + const { + pushToCreateProjectCommand, + workingWithProjectsHelpPath, + newProjectGuidelines, + hasErrors, + isCiCdAvailable, + } = el.dataset; - if (!el) { - return; - } + const props = { + hasErrors: parseBoolean(hasErrors), + isCiCdAvailable: parseBoolean(isCiCdAvailable), + newProjectGuidelines, + }; - const config = { - hasErrors: 'hasErrors' in el.dataset, - isCiCdAvailable: 'isCiCdAvailable' in el.dataset, - newProjectGuidelines: el.dataset.newProjectGuidelines, - }; - m.default(el, config); - }) - .catch(() => { - createFlash(__('An error occurred while loading project creation UI')); + const provide = { + workingWithProjectsHelpPath, + pushToCreateProjectCommand, + }; + + return new Vue({ + el, + components: { + NewProjectCreationApp, + }, + provide, + render(h) { + return h(NewProjectCreationApp, { props }); + }, }); +} + +const el = document.querySelector('.js-new-project-creation'); + +initNewProjectCreation(el); diff --git a/app/assets/javascripts/pages/projects/pipelines/new/index.js b/app/assets/javascripts/pages/projects/pipelines/new/index.js index 32299287a9c..e1f71965853 100644 --- a/app/assets/javascripts/pages/projects/pipelines/new/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/new/index.js @@ -1,17 +1,3 @@ -import $ from 'jquery'; -import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; -import NewBranchForm from '~/new_branch_form'; -import initNewPipeline from '~/pipeline_new/index'; +import initNewPipelineForm from '~/pipeline_new/index'; -const el = document.getElementById('js-new-pipeline'); - -if (el) { - initNewPipeline(); -} else { - new NewBranchForm($('.js-new-pipeline-form')); // eslint-disable-line no-new - - setupNativeFormVariableList({ - container: $('.js-ci-variable-list-section'), - formField: 'variables_attributes', - }); -} +initNewPipelineForm(); diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index be9259ec3ca..10105af3561 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -3,9 +3,9 @@ import SecretValues from '~/behaviors/secret_values'; import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; import initVariableList from '~/ci_variable_list'; import initDeployFreeze from '~/deploy_freeze'; +import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; -import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { @@ -36,10 +36,6 @@ document.addEventListener('DOMContentLoaded', () => { initSettingsPipelinesTriggers(); initArtifactsSettings(); - - if (gon?.features?.vueifySharedRunnersToggle) { - initSharedRunnersToggle(); - } - + initSharedRunnersToggle(); initInstallRunner(); }); diff --git a/app/assets/javascripts/pages/projects/settings/packages_and_registries/show/index.js b/app/assets/javascripts/pages/projects/settings/packages_and_registries/show/index.js new file mode 100644 index 00000000000..93c6a2c63a3 --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/packages_and_registries/show/index.js @@ -0,0 +1,5 @@ +import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle'; +import initSettingsPanels from '~/settings_panels'; + +registrySettingsApp(); +initSettingsPanels(); diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js index 8d390c8586b..380091a3501 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/form.js +++ b/app/assets/javascripts/pages/projects/settings/repository/form.js @@ -1,7 +1,7 @@ /* eslint-disable no-new */ +import initDatePicker from '~/behaviors/date_picker'; import initDeployKeys from '~/deploy_keys'; -import DueDateSelectors from '~/due_date_select'; import fileUpload from '~/lib/utils/file_upload'; import ProtectedBranchCreate from '~/protected_branches/protected_branch_create'; import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list'; @@ -16,6 +16,6 @@ export default () => { initSettingsPanels(); new ProtectedBranchCreate({ hasLicense: false }); new ProtectedBranchEditList(); - new DueDateSelectors(); + initDatePicker(); // Used for deploy token "expires at" field fileUpload('.js-choose-file', '.js-object-map-input'); }; diff --git a/app/assets/javascripts/pages/projects/snippets/show/index.js b/app/assets/javascripts/pages/projects/snippets/show/index.js index f955a41e18a..c719601ee0b 100644 --- a/app/assets/javascripts/pages/projects/snippets/show/index.js +++ b/app/assets/javascripts/pages/projects/snippets/show/index.js @@ -1 +1,9 @@ import '~/snippet/snippet_show'; + +const awardEmojiEl = document.getElementById('js-vue-awards-block'); + +if (awardEmojiEl) { + import('~/emoji/awards_app') + .then((m) => m.default(awardEmojiEl)) + .catch(() => {}); +} diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 6afc33ec8a5..43753926039 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -1,9 +1,21 @@ <script> -import { GlForm, GlIcon, GlLink, GlButton, GlSprintf } from '@gitlab/ui'; +import { + GlForm, + GlIcon, + GlLink, + GlButton, + GlSprintf, + GlAlert, + GlLoadingIcon, + GlModal, + GlModalDirective, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; import csrf from '~/lib/utils/csrf'; import { setUrlFragment } from '~/lib/utils/url_utility'; -import { __, s__, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const MARKDOWN_LINK_TEXT = { markdown: '[Link Title](page-slug)', @@ -13,21 +25,98 @@ const MARKDOWN_LINK_TEXT = { }; export default { + i18n: { + title: { + label: s__('WikiPage|Title'), + placeholder: s__('WikiPage|Page title'), + helpText: { + existingPage: s__( + 'WikiPage|Tip: You can move this page by adding the path to the beginning of the title.', + ), + newPage: s__( + 'WikiPage|Tip: You can specify the full path for the new file. We will automatically create any missing directories.', + ), + moreInformation: s__('WikiPage|More Information.'), + }, + }, + format: { + label: s__('WikiPage|Format'), + }, + content: { + label: s__('WikiPage|Content'), + placeholder: s__('WikiPage|Write your content or drag files here…'), + }, + contentEditor: { + renderFailed: { + message: s__( + 'WikiPage|An error occured while trying to render the content editor. Please try again later.', + ), + primaryAction: s__('WikiPage|Retry'), + }, + useNewEditor: s__('WikiPage|Use new editor'), + switchToOldEditor: { + label: s__('WikiPage|Switch to old editor'), + helpText: s__("WikiPage|Switching will discard any changes you've made in the new editor."), + modal: { + title: s__('WikiPage|Are you sure you want to switch to the old editor?'), + primary: s__('WikiPage|Switch to old editor'), + cancel: s__('WikiPage|Keep editing'), + text: s__( + "WikiPage|Switching to the old editor will discard any changes you've made in the new editor.", + ), + }, + }, + helpText: s__( + "WikiPage|This editor is in beta and may not display the page's contents properly.", + ), + }, + linksHelpText: s__( + 'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.', + ), + commitMessage: { + label: s__('WikiPage|Commit message'), + value: { + existingPage: s__('WikiPage|Update %{pageTitle}'), + newPage: s__('WikiPage|Create %{pageTitle}'), + }, + }, + submitButton: { + existingPage: s__('WikiPage|Save changes'), + newPage: s__('WikiPage|Create page'), + }, + cancel: s__('WikiPage|Cancel'), + }, components: { + GlAlert, GlForm, GlSprintf, GlIcon, GlLink, GlButton, + GlModal, MarkdownField, + GlLoadingIcon, + ContentEditor: () => + import( + /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue' + ), + }, + directives: { + GlModalDirective, }, + mixins: [glFeatureFlagMixin()], inject: ['formatOptions', 'pageInfo'], data() { return { title: this.pageInfo.title?.trim() || '', format: this.pageInfo.format || 'markdown', content: this.pageInfo.content?.trim() || '', + isContentEditorLoading: true, + useContentEditor: false, commitMessage: '', + contentEditor: null, + isDirty: false, + contentEditorRenderFailed: false, }; }, computed: { @@ -45,15 +134,21 @@ export default { }, commitMessageI18n() { return this.pageInfo.persisted - ? s__('WikiPage|Update %{pageTitle}') - : s__('WikiPage|Create %{pageTitle}'); + ? this.$options.i18n.commitMessage.value.existingPage + : this.$options.i18n.commitMessage.value.newPage; }, linkExample() { return MARKDOWN_LINK_TEXT[this.format]; }, submitButtonText() { - if (this.pageInfo.persisted) return __('Save changes'); - return s__('WikiPage|Create page'); + return this.pageInfo.persisted + ? this.$options.i18n.submitButton.existingPage + : this.$options.i18n.submitButton.newPage; + }, + titleHelpText() { + return this.pageInfo.persisted + ? this.$options.i18n.title.helpText.existingPage + : this.$options.i18n.title.helpText.newPage; }, cancelFormPath() { if (this.pageInfo.persisted) return this.pageInfo.path; @@ -62,20 +157,53 @@ export default { wikiSpecificMarkdownHelpPath() { return setUrlFragment(this.pageInfo.markdownHelpPath, 'wiki-specific-markdown'); }, + isMarkdownFormat() { + return this.format === 'markdown'; + }, + showContentEditorButton() { + return this.isMarkdownFormat && !this.useContentEditor && this.glFeatures.wikiContentEditor; + }, + disableSubmitButton() { + return !this.content || !this.title || this.contentEditorRenderFailed; + }, + isContentEditorActive() { + return this.isMarkdownFormat && this.useContentEditor; + }, }, mounted() { this.updateCommitMessage(); + + window.addEventListener('beforeunload', this.onPageUnload); + }, + destroyed() { + window.removeEventListener('beforeunload', this.onPageUnload); }, methods: { + getContentHTML(content) { + return axios + .post(this.pageInfo.markdownPreviewPath, { text: content }) + .then(({ data }) => data.body); + }, + handleFormSubmit() { - window.removeEventListener('beforeunload', this.onBeforeUnload); + if (this.useContentEditor) { + this.content = this.contentEditor.getSerializedContent(); + } + + this.isDirty = false; }, handleContentChange() { - window.addEventListener('beforeunload', this.onBeforeUnload); + this.isDirty = true; }, - onBeforeUnload() { + onPageUnload(event) { + if (!this.isDirty) return undefined; + + event.preventDefault(); + + // eslint-disable-next-line no-param-reassign + event.returnValue = ''; return ''; }, @@ -88,6 +216,48 @@ export default { const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: newTitle }, false); this.commitMessage = newCommitMessage; }, + + async initContentEditor() { + this.isContentEditorLoading = true; + this.useContentEditor = true; + + const { createContentEditor } = await import( + /* webpackChunkName: 'content_editor' */ '~/content_editor/services/create_content_editor' + ); + this.contentEditor = + this.contentEditor || + createContentEditor({ + renderMarkdown: (markdown) => this.getContentHTML(markdown), + tiptapOptions: { + onUpdate: () => this.handleContentChange(), + }, + }); + + try { + await this.contentEditor.setSerializedContent(this.content); + this.isContentEditorLoading = false; + } catch (e) { + this.contentEditorRenderFailed = true; + } + }, + + retryInitContentEditor() { + this.contentEditorRenderFailed = false; + this.initContentEditor(); + }, + + switchToOldEditor() { + this.useContentEditor = false; + }, + + confirmSwitchToOldEditor() { + if (this.contentEditorRenderFailed) { + this.contentEditorRenderFailed = false; + this.switchToOldEditor(); + } else { + this.$refs.confirmSwitchToOldEditorModal.show(); + } + }, }, }; </script> @@ -99,6 +269,19 @@ export default { class="wiki-form common-note-form gl-mt-3 js-quick-submit" @submit="handleFormSubmit" > + <gl-alert + v-if="isContentEditorActive && contentEditorRenderFailed" + class="gl-mb-6" + :dismissible="false" + variant="danger" + :primary-button-text="$options.i18n.contentEditor.renderFailed.primaryAction" + @primaryAction="retryInitContentEditor()" + > + <p> + {{ $options.i18n.contentEditor.renderFailed.message }} + </p> + </gl-alert> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> <input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" /> <input @@ -109,7 +292,9 @@ export default { /> <div class="form-group row"> <div class="col-sm-2 col-form-label"> - <label class="control-label-full-width" for="wiki_title">{{ s__('WikiPage|Title') }}</label> + <label class="control-label-full-width" for="wiki_title">{{ + $options.i18n.title.label + }}</label> </div> <div class="col-sm-10"> <input @@ -121,22 +306,15 @@ export default { data-qa-selector="wiki_title_textbox" :required="true" :autofocus="!pageInfo.persisted" - :placeholder="s__('WikiPage|Page title')" + :placeholder="$options.i18n.title.placeholder" @input="updateCommitMessage" /> <span class="gl-display-inline-block gl-max-w-full gl-mt-2 gl-text-gray-600"> <gl-icon class="gl-mr-n1" name="bulb" /> - {{ - pageInfo.persisted - ? s__( - 'WikiPage|Tip: You can move this page by adding the path to the beginning of the title.', - ) - : s__( - 'WikiPage|Tip: You can specify the full path for the new file. We will automatically create any missing directories.', - ) - }} - <gl-link :href="helpPath" target="_blank" data-testid="wiki-title-help-link" - ><gl-icon name="question-o" /> {{ __('More Information.') }}</gl-link + {{ titleHelpText }} + <gl-link :href="helpPath" target="_blank" + ><gl-icon name="question-o" /> + {{ $options.i18n.title.helpText.moreInformation }}</gl-link > </span> </div> @@ -144,25 +322,63 @@ export default { <div class="form-group row"> <div class="col-sm-2 col-form-label"> <label class="control-label-full-width" for="wiki_format">{{ - s__('WikiPage|Format') + $options.i18n.format.label }}</label> </div> <div class="col-sm-10"> - <select id="wiki_format" v-model="format" class="form-control" name="wiki[format]"> + <select + id="wiki_format" + v-model="format" + class="form-control" + name="wiki[format]" + :disabled="isContentEditorActive" + > <option v-for="(key, label) of formatOptions" :key="key" :value="key"> {{ label }} </option> </select> + <div> + <gl-button + v-if="showContentEditorButton" + category="secondary" + variant="confirm" + class="gl-mt-4" + @click="initContentEditor" + >{{ $options.i18n.contentEditor.useNewEditor }}</gl-button + > + <div v-if="isContentEditorActive" class="gl-mt-4 gl-display-flex"> + <div class="gl-mr-4"> + <gl-button category="secondary" variant="confirm" @click="confirmSwitchToOldEditor">{{ + $options.i18n.contentEditor.switchToOldEditor.label + }}</gl-button> + </div> + <div class="gl-mt-2"> + <gl-icon name="warning" /> + {{ $options.i18n.contentEditor.switchToOldEditor.helpText }} + </div> + </div> + <gl-modal + ref="confirmSwitchToOldEditorModal" + modal-id="confirm-switch-to-old-editor" + :title="$options.i18n.contentEditor.switchToOldEditor.modal.title" + :action-primary="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.primary }" + :action-cancel="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.cancel }" + @primary="switchToOldEditor" + > + {{ $options.i18n.contentEditor.switchToOldEditor.modal.text }} + </gl-modal> + </div> </div> </div> <div class="form-group row"> <div class="col-sm-2 col-form-label"> <label class="control-label-full-width" for="wiki_content">{{ - s__('WikiPage|Content') + $options.i18n.content.label }}</label> </div> <div class="col-sm-10"> <markdown-field + v-if="!isContentEditorActive" :markdown-preview-path="pageInfo.markdownPreviewPath" :can-attach-file="true" :enable-autocomplete="true" @@ -182,24 +398,25 @@ export default { data-supports-quick-actions="false" data-qa-selector="wiki_content_textarea" :autofocus="pageInfo.persisted" - :aria-label="s__('WikiPage|Content')" - :placeholder="s__('WikiPage|Write your content or drag files here…')" + :aria-label="$options.i18n.content.label" + :placeholder="$options.i18n.content.placeholder" @input="handleContentChange" > </textarea> </template> </markdown-field> + + <div v-if="isContentEditorActive"> + <gl-loading-icon v-if="isContentEditorLoading" class="bordered-box gl-w-full gl-py-6" /> + <content-editor v-else :content-editor="contentEditor" /> + <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" /> + </div> + <div class="clearfix"></div> <div class="error-alert"></div> <div class="form-text gl-text-gray-600"> - <gl-sprintf - :message=" - s__( - 'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.', - ) - " - > + <gl-sprintf v-if="!isContentEditorActive" :message="$options.i18n.linksHelpText"> <template #linkExample ><code>{{ linkExample }}</code></template > @@ -214,13 +431,16 @@ export default { ></template > </gl-sprintf> + <span v-else> + {{ $options.i18n.contentEditor.helpText }} + </span> </div> </div> </div> <div class="form-group row"> <div class="col-sm-2 col-form-label"> <label class="control-label-full-width" for="wiki_message">{{ - s__('WikiPage|Commit message') + $options.i18n.commitMessage.label }}</label> </div> <div class="col-sm-10"> @@ -231,7 +451,7 @@ export default { type="text" class="form-control" data-qa-selector="wiki_message_textbox" - :placeholder="s__('WikiPage|Commit message')" + :placeholder="$options.i18n.commitMessage.label" /> </div> </div> @@ -242,12 +462,10 @@ export default { type="submit" data-qa-selector="wiki_submit_button" data-testid="wiki-submit-button" - :disabled="!content || !title" + :disabled="disableSubmitButton" >{{ submitButtonText }}</gl-button > - <gl-button :href="cancelFormPath" class="float-right" data-testid="wiki-cancel-button">{{ - __('Cancel') - }}</gl-button> + <gl-button :href="cancelFormPath" class="float-right">{{ $options.i18n.cancel }}</gl-button> </div> </gl-form> </template> diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index d236dc4610a..c416106fdd8 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -247,7 +247,7 @@ export default class ActivityCalendar { renderKey() { const keyValues = [ - __('no contributions'), + __('No contributions'), __('1-9 contributions'), __('10-19 contributions'), __('20-29 contributions'), diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 80e14842f51..f9d70845560 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -223,14 +223,14 @@ export default class UserTabs { .then((data) => UserTabs.renderActivityCalendar(data, $calendarWrap)) .catch(() => { const cWrap = $calendarWrap[0]; - cWrap.querySelector('.spinner').classList.add('invisible'); + cWrap.querySelector('.gl-spinner').classList.add('invisible'); cWrap.querySelector('.user-calendar-error').classList.remove('invisible'); cWrap .querySelector('.user-calendar-error .js-retry-load') .addEventListener('click', (e) => { e.preventDefault(); cWrap.querySelector('.user-calendar-error').classList.add('invisible'); - cWrap.querySelector('.spinner').classList.remove('invisible'); + cWrap.querySelector('.gl-spinner').classList.remove('invisible'); this.loadActivityCalendar(); }); }); diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js index 98b2e4238c1..1db80057d0c 100644 --- a/app/assets/javascripts/performance/constants.js +++ b/app/assets/javascripts/performance/constants.js @@ -43,6 +43,7 @@ export const WEBIDE_MEASURE_FETCH_FILES = 'WebIDE: Fetch Files'; // // MR Diffs namespace +// // Marks export const MR_DIFFS_MARK_FILE_TREE_START = 'mr-diffs-mark-file-tree-start'; @@ -75,3 +76,14 @@ export const PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION = export const PIPELINES_DETAIL_LINK_DURATION = 'pipeline_graph_link_calculation_duration_seconds'; export const PIPELINES_DETAIL_LINKS_TOTAL = 'pipeline_graph_links_total'; export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_links_per_job_ratio'; + +// +// REPO BROWSER NAMESPACE +// + +// Marks +export const REPO_BLOB_LOAD_VIEWER_START = 'blobviewer-load-viewer-start'; +export const REPO_BLOB_LOAD_VIEWER_FINISH = 'blobviewer-load-viewer-finish'; + +// Measures +export const REPO_BLOB_LOAD_VIEWER = 'Repository File Viewer: loading the content'; diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue index 14a4a9d5710..567164cb0ee 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue @@ -11,6 +11,7 @@ import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphq import getCommitSha from '../../graphql/queries/client/commit_sha.graphql'; import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql'; import getIsNewCiConfigFile from '../../graphql/queries/client/is_new_ci_config_file.graphql'; +import getPipelineEtag from '../../graphql/queries/client/pipeline_etag.graphql'; import CommitForm from './commit_form.vue'; @@ -94,10 +95,15 @@ export default { }, update(store, { data }) { const commitSha = data?.commitCreate?.commit?.sha; + const pipelineEtag = data?.commitCreate?.commit?.commitPipelinePath; if (commitSha) { store.writeQuery({ query: getCommitSha, data: { commitSha } }); } + + if (pipelineEtag) { + store.writeQuery({ query: getPipelineEtag, data: { pipelineEtag } }); + } }, }); diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue new file mode 100644 index 00000000000..22c1563350d --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue @@ -0,0 +1,67 @@ +<script> +import { GlCard, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import PipelineVisualReference from '../ui/pipeline_visual_reference.vue'; + +export default { + i18n: { + title: s__('PipelineEditorTutorial|🚀 Run your first pipeline'), + firstParagraph: s__( + 'PipelineEditorTutorial|A typical GitLab pipeline consists of three stages: build, test and deploy. Each stage can have one or more jobs.', + ), + secondParagraph: s__( + 'PipelineEditorTutorial|In the example below, %{codeStart}build%{codeEnd} and %{codeStart}deploy%{codeEnd} each contain one job, and %{codeStart}test%{codeEnd} contains two jobs. Your scripts run in jobs like these.', + ), + thirdParagraph: s__( + 'PipelineEditorTutorial|You can use %{linkStart}CI/CD examples and templates%{linkEnd} to get your first %{codeStart}.gitlab-ci.yml%{codeEnd} configuration file started. Your first pipeline runs when you commit the changes.', + ), + note: s__( + 'PipelineEditorTutorial|If you’re using a self-managed GitLab instance, %{linkStart}make sure your instance has runners available.%{linkEnd}', + ), + }, + components: { + GlCard, + GlLink, + GlSprintf, + PipelineVisualReference, + }, + inject: ['ciExamplesHelpPagePath', 'runnerHelpPagePath'], +}; +</script> +<template> + <gl-card> + <template #default> + <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <p class="gl-mb-3"> + <gl-sprintf :message="$options.i18n.secondParagraph"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <pipeline-visual-reference /> + <p class="gl-my-3"> + <gl-sprintf :message="$options.i18n.thirdParagraph"> + <template #link="{ content }"> + <gl-link :href="ciExamplesHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.note"> + <template #link="{ content }"> + <gl-link :href="runnerHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + </template> + </gl-card> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue new file mode 100644 index 00000000000..3da535f5f94 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue @@ -0,0 +1,35 @@ +<script> +import { GlCard, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('PipelineEditorTutorial|Get started with GitLab CI/CD'), + firstParagraph: s__( + 'PipelineEditorTutorial|GitLab CI/CD can automatically build, test, and deploy your application.', + ), + secondParagraph: s__( + 'PipelineEditorTutorial|The pipeline stages and jobs are defined in a %{codeStart}.gitlab-ci.yml%{codeEnd} file. You can edit, visualize and validate the syntax in this file by using the Pipeline Editor.', + ), + }, + components: { + GlCard, + GlSprintf, + }, +}; +</script> +<template> + <gl-card> + <template #default> + <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <p class="gl-mb-0"> + <gl-sprintf :message="$options.i18n.secondParagraph"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </template> + </gl-card> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue new file mode 100644 index 00000000000..f714f6411f1 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue @@ -0,0 +1,75 @@ +<script> +import { GlCard, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('PipelineEditorTutorial|⚙️ Pipeline configuration reference'), + firstParagraph: s__('PipelineEditorTutorial|Resources to help with your CI/CD configuration:'), + browseExamples: s__( + 'PipelineEditorTutorial|Browse %{linkStart}CI/CD examples and templates%{linkEnd}', + ), + viewSyntaxRef: s__( + 'PipelineEditorTutorial|View %{linkStart}.gitlab-ci.yml syntax reference%{linkEnd}', + ), + learnMore: s__( + 'PipelineEditorTutorial|Learn more about %{linkStart}GitLab CI/CD concepts%{linkEnd}', + ), + needs: s__( + 'PipelineEditorTutorial|Make your pipeline more efficient with the %{linkStart}Needs keyword%{linkEnd}', + ), + }, + components: { + GlCard, + GlLink, + GlSprintf, + }, + inject: ['ciExamplesHelpPagePath', 'ciHelpPagePath', 'needsHelpPagePath', 'ymlHelpPagePath'], +}; +</script> +<template> + <gl-card> + <template #default> + <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> + <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p> + <ul> + <li> + <gl-sprintf :message="$options.i18n.browseExamples"> + <template #link="{ content }"> + <gl-link :href="ciExamplesHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.viewSyntaxRef"> + <template #link="{ content }"> + <gl-link :href="ymlHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.learnMore"> + <template #link="{ content }"> + <gl-link :href="ciHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + <li> + <gl-sprintf :message="$options.i18n.needs"> + <template #link="{ content }"> + <gl-link :href="needsHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </li> + </ul> + </template> + </gl-card> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue new file mode 100644 index 00000000000..512414f0246 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue @@ -0,0 +1,24 @@ +<script> +import { GlCard } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('PipelineEditorTutorial|💡 Tip: Visualize and validate your pipeline'), + firstParagraph: s__( + 'PipelineEditorTutorial|Use the Visualize and Lint tabs in the Pipeline Editor to visualize your pipeline and check for any errors or warnings before committing your changes.', + ), + }, + components: { + GlCard, + }, +}; +</script> +<template> + <gl-card> + <template #default> + <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4> + <p class="gl-mb-0">{{ $options.i18n.firstParagraph }}</p> + </template> + </gl-card> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue new file mode 100644 index 00000000000..ff1e0b6388f --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -0,0 +1,105 @@ +<script> +import { GlButton, GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { DRAWER_EXPANDED_KEY } from '../../constants'; +import FirstPipelineCard from './cards/first_pipeline_card.vue'; +import GettingStartedCard from './cards/getting_started_card.vue'; +import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue'; +import VisualizeAndLintCard from './cards/visualize_and_lint_card.vue'; + +export default { + width: { + expanded: '482px', + collapsed: '58px', + }, + i18n: { + toggleTxt: __('Collapse'), + }, + localDrawerKey: DRAWER_EXPANDED_KEY, + components: { + FirstPipelineCard, + GettingStartedCard, + GlButton, + GlIcon, + LocalStorageSync, + PipelineConfigReferenceCard, + VisualizeAndLintCard, + }, + data() { + return { + isExpanded: false, + topPosition: 0, + }; + }, + computed: { + buttonIconName() { + return this.isExpanded ? 'chevron-double-lg-right' : 'chevron-double-lg-left'; + }, + buttonClass() { + return this.isExpanded ? 'gl-justify-content-end!' : ''; + }, + rootStyle() { + const { expanded, collapsed } = this.$options.width; + const top = this.topPosition; + const style = { top: `${top}px` }; + + return this.isExpanded ? { ...style, width: expanded } : { ...style, width: collapsed }; + }, + }, + mounted() { + this.setTopPosition(); + this.setInitialExpandState(); + }, + methods: { + setInitialExpandState() { + // We check in the local storage and if no value is defined, we want the default + // to be true. We want to explicitly set it to true here so that the drawer + // animates to open on load. + const localValue = localStorage.getItem(this.$options.localDrawerKey); + if (localValue === null) { + this.isExpanded = true; + } + }, + setTopPosition() { + const navbarEl = document.querySelector('.js-navbar'); + + if (navbarEl) { + this.topPosition = navbarEl.getBoundingClientRect().bottom; + } + }, + toggleDrawer() { + this.isExpanded = !this.isExpanded; + }, + }, +}; +</script> +<template> + <local-storage-sync v-model="isExpanded" :storage-key="$options.localDrawerKey" as-json> + <aside + aria-live="polite" + class="gl-fixed gl-right-0 gl-bg-gray-10 gl-shadow-drawer gl-transition-property-width gl-transition-duration-medium gl-border-l-solid gl-border-1 gl-border-gray-100 gl-h-full gl-z-index-3 gl-overflow-y-auto" + :style="rootStyle" + > + <gl-button + category="tertiary" + class="gl-w-full gl-h-9 gl-rounded-0! gl-border-none! gl-border-b-solid! gl-border-1! gl-border-gray-100 gl-text-decoration-none! gl-outline-0! gl-display-flex" + :class="buttonClass" + :title="__('Toggle sidebar')" + @click="toggleDrawer" + > + <span v-if="isExpanded" class="gl-text-gray-500 gl-mr-3" data-testid="collapse-text"> + {{ __('Collapse') }} + </span> + <gl-icon data-testid="toggle-icon" :name="buttonIconName" /> + </gl-button> + <div v-if="isExpanded" class="gl-h-full gl-p-5" data-testid="drawer-content"> + <getting-started-card class="gl-mb-4" /> + <first-pipeline-card class="gl-mb-4" /> + <visualize-and-lint-card class="gl-mb-4" /> + <pipeline-config-reference-card /> + <div class="gl-h-13"></div> + </div> + </aside> + </local-storage-sync> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue b/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue new file mode 100644 index 00000000000..049504181c4 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue @@ -0,0 +1,17 @@ +<script> +export default { + props: { + jobName: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div + class="gl-w-13 gl-h-6 gl-font-sm gl-bg-white gl-inset-border-1-blue-500 gl-text-center gl-text-truncate gl-rounded-pill gl-px-4 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" + > + {{ jobName }} + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue b/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue new file mode 100644 index 00000000000..1017237365b --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue @@ -0,0 +1,43 @@ +<script> +import { s__ } from '~/locale'; +import DemoJobPill from './demo_job_pill.vue'; + +export default { + i18n: { + stageNames: { + build: s__('StageName|Build'), + test: s__('StageName|Test'), + deploy: s__('StageName|Deploy'), + }, + jobNames: { + build: s__('JobName|build-job'), + test_1: s__('JobName|unit-test'), + test_2: s__('JobName|lint-test'), + deploy: s__('JobName|deploy-app'), + }, + }, + stageClasses: + 'gl-bg-blue-50 gl-display-flex gl-flex-direction-column gl-align-items-center gl-p-4 gl-rounded-base', + titleClasses: 'gl-text-blue-600 gl-mb-4', + components: { + DemoJobPill, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-justify-content-center"> + <div :class="$options.stageClasses" class="gl-mr-5"> + <div :class="$options.titleClasses">{{ $options.i18n.stageNames.build }}</div> + <demo-job-pill :job-name="$options.i18n.jobNames.build" /> + </div> + <div :class="$options.stageClasses" class="gl-mr-5"> + <div :class="$options.titleClasses">{{ $options.i18n.stageNames.test }}</div> + <demo-job-pill class="gl-mb-3" :job-name="$options.i18n.jobNames.test_1" /> + <demo-job-pill :job-name="$options.i18n.jobNames.test_2" /> + </div> + <div :class="$options.stageClasses"> + <div :class="$options.titleClasses">{{ $options.i18n.stageNames.deploy }}</div> + <demo-job-pill :job-name="$options.i18n.jobNames.deploy" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue index b3eba0fcc19..1acf3a03e73 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -1,32 +1,77 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlIcon } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlInfiniteScroll, + GlLoadingIcon, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { historyPushState } from '~/lib/utils/common_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; -import { DEFAULT_FAILURE } from '~/pipeline_editor/constants'; +import { + BRANCH_PAGINATION_LIMIT, + BRANCH_SEARCH_DEBOUNCE, + DEFAULT_FAILURE, +} from '~/pipeline_editor/constants'; import getAvailableBranches from '~/pipeline_editor/graphql/queries/available_branches.graphql'; import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.graphql'; export default { i18n: { + dropdownHeader: s__('Switch Branch'), title: s__('Branches'), fetchError: s__('Unable to fetch branch list for this project.'), }, + inputDebounce: BRANCH_SEARCH_DEBOUNCE, components: { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, - GlIcon, + GlInfiniteScroll, + GlLoadingIcon, + GlSearchBoxByType, + }, + inject: ['projectFullPath', 'totalBranches'], + props: { + paginationLimit: { + type: Number, + required: false, + default: BRANCH_PAGINATION_LIMIT, + }, + }, + data() { + return { + branches: [], + page: { + limit: this.paginationLimit, + offset: 0, + searchTerm: '', + }, + }; }, - inject: ['projectFullPath'], apollo: { - branches: { + availableBranches: { query: getAvailableBranches, variables() { return { + limit: this.page.limit, + offset: this.page.offset, projectFullPath: this.projectFullPath, + searchPattern: this.searchPattern, }; }, update(data) { - return data.project?.repository?.branches || []; + return data.project?.repository?.branchNames || []; + }, + result({ data }) { + const newBranches = data.project?.repository?.branchNames || []; + + // check that we're not re-concatenating existing fetch results + if (!this.branches.includes(newBranches[0])) { + this.branches = this.branches.concat(newBranches); + } }, error() { this.$emit('showError', { @@ -40,26 +85,99 @@ export default { }, }, computed: { - hasBranchList() { - return this.branches?.length > 0; + isBranchesLoading() { + return this.$apollo.queries.availableBranches.loading; + }, + showBranchSwitcher() { + return this.branches.length > 0 || this.page.searchTerm.length > 0; + }, + searchPattern() { + if (this.page.searchTerm === '') { + return '*'; + } + + return `*${this.page.searchTerm}*`; + }, + }, + methods: { + // if there is no searchPattern, paginate by {paginationLimit} branches + fetchNextBranches() { + if ( + this.isBranchesLoading || + this.page.searchTerm.length > 0 || + this.branches.length === this.totalBranches + ) { + return; + } + + this.page = { + ...this.page, + limit: this.paginationLimit, + offset: this.page.offset + this.paginationLimit, + }; + }, + async selectBranch(newBranch) { + if (newBranch === this.currentBranch) { + return; + } + + await this.$apollo.getClient().writeQuery({ + query: getCurrentBranch, + data: { currentBranch: newBranch }, + }); + + const updatedPath = setUrlParams({ branch_name: newBranch }); + historyPushState(updatedPath); + + this.$emit('refetchContent'); + }, + setSearchTerm(newSearchTerm) { + this.branches = []; + this.page = { + limit: newSearchTerm.trim() === '' ? this.paginationLimit : this.totalBranches, + offset: 0, + searchTerm: newSearchTerm.trim(), + }; }, }, }; </script> <template> - <gl-dropdown v-if="hasBranchList" class="gl-ml-2" :text="currentBranch" icon="branch"> + <gl-dropdown + v-if="showBranchSwitcher" + class="gl-ml-2" + :header-text="$options.i18n.dropdownHeader" + :text="currentBranch" + icon="branch" + > + <gl-search-box-by-type :debounce="$options.inputDebounce" @input="setSearchTerm" /> <gl-dropdown-section-header> - {{ this.$options.i18n.title }} + {{ $options.i18n.title }} </gl-dropdown-section-header> - <gl-dropdown-item - v-for="branch in branches" - :key="branch.name" - :is-checked="currentBranch === branch.name" - :is-check-item="true" + + <gl-infinite-scroll + :fetched-items="branches.length" + :total-items="totalBranches" + :max-list-height="250" + @bottomReached="fetchNextBranches" > - <gl-icon name="check" class="gl-visibility-hidden" /> - {{ branch.name }} - </gl-dropdown-item> + <template #items> + <gl-dropdown-item + v-for="branch in branches" + :key="branch" + :is-checked="currentBranch === branch" + :is-check-item="true" + @click="selectBranch(branch)" + > + {{ branch }} + </gl-dropdown-item> + </template> + <template #default> + <gl-dropdown-item v-if="isBranchesLoading" key="loading"> + <gl-loading-icon size="md" /> + </gl-dropdown-item> + </template> + </gl-infinite-scroll> </gl-dropdown> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue index fefa784f060..24bca04e115 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue @@ -1,5 +1,4 @@ <script> -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import PipelineStatus from './pipeline_status.vue'; import ValidationSegment from './validation_segment.vue'; @@ -29,7 +28,6 @@ export default { PipelineStatus, ValidationSegment, }, - mixins: [glFeatureFlagsMixin()], props: { ciConfigData: { type: Object, @@ -42,7 +40,7 @@ export default { }, computed: { showPipelineStatus() { - return this.glFeatures.pipelineStatusForPipelineEditor && !this.isNewCiConfigFile; + return !this.isNewCiConfigFile; }, // make sure corners are rounded correctly depending on if // pipeline status is rendered diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index 4a92e106da1..368a026bdaa 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -5,7 +5,11 @@ import { truncateSha } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql'; import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; -import { toggleQueryPollingByVisibility } from '~/pipelines/components/graph/utils'; +import getPipelineEtag from '~/pipeline_editor/graphql/queries/client/pipeline_etag.graphql'; +import { + getQueryHeaders, + toggleQueryPollingByVisibility, +} from '~/pipelines/components/graph/utils'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; const POLL_INTERVAL = 10000; @@ -31,7 +35,13 @@ export default { commitSha: { query: getCommitSha, }, + pipelineEtag: { + query: getPipelineEtag, + }, pipeline: { + context() { + return getQueryHeaders(this.pipelineEtag); + }, query: getPipelineQuery, variables() { return { diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index 5acb3355b23..4e2f26af51d 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -110,7 +110,6 @@ export default { <text-editor :value="ciFileContent" v-on="$listeners" /> </editor-tab> <editor-tab - v-if="glFeatures.ciConfigVisualizationTab" class="gl-mb-3" :empty-message="$options.i18n.empty.visualization" :is-empty="isEmpty" @@ -135,7 +134,6 @@ export default { <ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" /> </editor-tab> <editor-tab - v-if="glFeatures.ciConfigMergedTab" class="gl-mb-3" :empty-message="$options.i18n.empty.merge" :keep-component-mounted="false" diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue index d4f04a0d055..0ac4a40ff4a 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue @@ -1,12 +1,14 @@ <script> import { GlButton, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; +import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { GlButton, GlSprintf, + PipelineEditorFileNav, }, i18n: { title: __('Optimize your workflow with CI/CD Pipelines'), @@ -22,6 +24,9 @@ export default { }, }, computed: { + showFileNav() { + return this.glFeatures.pipelineEditorBranchSwitcher; + }, showCTAButton() { return this.glFeatures.pipelineEditorEmptyStateAction; }, @@ -34,23 +39,26 @@ export default { }; </script> <template> - <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11"> - <img :src="emptyStateIllustrationPath" /> - <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> - <p class="gl-mt-3"> - <gl-sprintf :message="$options.i18n.body"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> - <gl-button - v-if="showCTAButton" - variant="confirm" - class="gl-mt-3" - @click="createEmptyConfigFile" - > - {{ $options.i18n.btnText }} - </gl-button> + <div> + <pipeline-editor-file-nav v-if="showFileNav" v-on="$listeners" /> + <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11"> + <img :src="emptyStateIllustrationPath" /> + <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> + <p class="gl-mt-3"> + <gl-sprintf :message="$options.i18n.body"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <gl-button + v-if="showCTAButton" + variant="confirm" + class="gl-mt-3" + @click="createEmptyConfigFile" + > + {{ $options.i18n.btnText }} + </gl-button> + </div> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue new file mode 100644 index 00000000000..091b202e10b --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue @@ -0,0 +1,155 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; +import { __, s__ } from '~/locale'; +import { + COMMIT_FAILURE, + COMMIT_SUCCESS, + DEFAULT_FAILURE, + DEFAULT_SUCCESS, + LOAD_FAILURE_UNKNOWN, +} from '../../constants'; +import CodeSnippetAlert from '../code_snippet_alert/code_snippet_alert.vue'; +import { + CODE_SNIPPET_SOURCE_URL_PARAM, + CODE_SNIPPET_SOURCES, +} from '../code_snippet_alert/constants'; + +export default { + components: { + GlAlert, + CodeSnippetAlert, + }, + errorTexts: { + [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), + [DEFAULT_FAILURE]: __('Something went wrong on our end.'), + [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), + }, + successTexts: { + [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), + [DEFAULT_SUCCESS]: __('Your action succeeded.'), + }, + props: { + failureType: { + type: String, + required: false, + default: null, + }, + failureReasons: { + type: Array, + required: false, + default: () => [], + }, + showFailure: { + type: Boolean, + required: false, + default: false, + }, + showSuccess: { + type: Boolean, + required: false, + default: false, + }, + successType: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + codeSnippetCopiedFrom: '', + }; + }, + computed: { + failure() { + switch (this.failureType) { + case LOAD_FAILURE_UNKNOWN: + return { + text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN], + variant: 'danger', + }; + case COMMIT_FAILURE: + return { + text: this.$options.errorTexts[COMMIT_FAILURE], + variant: 'danger', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT_FAILURE], + variant: 'danger', + }; + } + }, + success() { + switch (this.successType) { + case COMMIT_SUCCESS: + return { + text: this.$options.successTexts[COMMIT_SUCCESS], + variant: 'info', + }; + default: + return { + text: this.$options.successTexts[DEFAULT_SUCCESS], + variant: 'info', + }; + } + }, + }, + created() { + this.parseCodeSnippetSourceParam(); + }, + methods: { + dismissCodeSnippetAlert() { + this.codeSnippetCopiedFrom = ''; + }, + dismissFailure() { + this.$emit('hide-failure'); + }, + dismissSuccess() { + this.$emit('hide-success'); + }, + parseCodeSnippetSourceParam() { + const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM); + if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) { + this.codeSnippetCopiedFrom = codeSnippetCopiedFrom; + window.history.replaceState( + {}, + document.title, + removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]), + ); + } + }, + }, +}; +</script> + +<template> + <div> + <code-snippet-alert + v-if="codeSnippetCopiedFrom" + :source="codeSnippetCopiedFrom" + class="gl-mb-5" + @dismiss="dismissCodeSnippetAlert" + /> + <gl-alert + v-if="showSuccess" + :variant="success.variant" + class="gl-mb-5" + @dismiss="dismissSuccess" + > + {{ success.text }} + </gl-alert> + <gl-alert + v-if="showFailure" + :variant="failure.variant" + class="gl-mb-5" + @dismiss="dismissFailure" + > + {{ failure.text }} + <ul v-if="failureReasons.length" class="gl-mb-0"> + <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li> + </ul> + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index 8d0ec6c3e2d..f0a24e0c061 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -14,6 +14,7 @@ export const COMMIT_FAILURE = 'COMMIT_FAILURE'; export const COMMIT_SUCCESS = 'COMMIT_SUCCESS'; export const DEFAULT_FAILURE = 'DEFAULT_FAILURE'; +export const DEFAULT_SUCCESS = 'DEFAULT_SUCCESS'; export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; export const CREATE_TAB = 'CREATE_TAB'; @@ -25,3 +26,8 @@ export const TABS_WITH_COMMIT_FORM = [CREATE_TAB, LINT_TAB, VISUALIZE_TAB]; export const COMMIT_ACTION_CREATE = 'CREATE'; export const COMMIT_ACTION_UPDATE = 'UPDATE'; + +export const DRAWER_EXPANDED_KEY = 'pipeline_editor_drawer_expanded'; + +export const BRANCH_PAGINATION_LIMIT = 20; +export const BRANCH_SEARCH_DEBOUNCE = '500'; diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql index 3b2daa45a18..94e6facabfd 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql @@ -22,6 +22,7 @@ mutation commitCIFile( commit { sha } + commitPipelinePath errors } } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql index f162bb11d47..46e9b108b41 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql @@ -1,9 +1,12 @@ -query getAvailableBranches($projectFullPath: ID!) { - project(fullPath: $projectFullPath) @client { +query getAvailableBranches( + $limit: Int! + $offset: Int! + $projectFullPath: ID! + $searchPattern: String! +) { + project(fullPath: $projectFullPath) { repository { - branches { - name - } + branchNames(limit: $limit, offset: $offset, searchPattern: $searchPattern) } } } diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql new file mode 100644 index 00000000000..b9946a9e233 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql @@ -0,0 +1,3 @@ +query getPipelineEtag { + pipelineEtag @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index caa2a65d424..81e75c32846 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -11,23 +11,6 @@ export const resolvers = { }), }; }, - /* eslint-disable @gitlab/require-i18n-strings */ - project() { - return { - __typename: 'Project', - repository: { - __typename: 'Repository', - branches: [ - { __typename: 'Branch', name: 'master' }, - { __typename: 'Branch', name: 'main' }, - { __typename: 'Branch', name: 'develop' }, - { __typename: 'Branch', name: 'production' }, - { __typename: 'Branch', name: 'test' }, - ], - }, - }; - }, - /* eslint-enable @gitlab/require-i18n-strings */ }, Mutation: { lintCI: (_, { endpoint, content, dry_run }) => { diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 8a1e26f9bff..66158bdba88 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -6,6 +6,7 @@ import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants'; import getCommitSha from './graphql/queries/client/commit_sha.graphql'; import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; +import getPipelineEtag from './graphql/queries/client/pipeline_etag.graphql'; import { resolvers } from './graphql/resolvers'; import typeDefs from './graphql/typedefs.graphql'; import PipelineEditorApp from './pipeline_editor_app.vue'; @@ -26,15 +27,23 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { // Add to apollo cache as it can be updated by future queries commitSha, initialBranchName, + pipelineEtag, // Add to provide/inject API for static values ciConfigPath, + ciExamplesHelpPagePath, + ciHelpPagePath, defaultBranch, emptyStateIllustrationPath, + helpPaths, lintHelpPagePath, + needsHelpPagePath, newMergeRequestPath, + pipelinePagePath, projectFullPath, projectPath, projectNamespace, + runnerHelpPagePath, + totalBranches, ymlHelpPagePath, } = el?.dataset; @@ -48,7 +57,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(resolvers, { typeDefs }), + defaultClient: createDefaultClient(resolvers, { typeDefs, useGet: true }), }); const { cache } = apolloProvider.clients.defaultClient; @@ -66,20 +75,34 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { }, }); + cache.writeQuery({ + query: getPipelineEtag, + data: { + pipelineEtag, + }, + }); + return new Vue({ el, apolloProvider, provide: { ciConfigPath, + ciExamplesHelpPagePath, + ciHelpPagePath, + configurationPaths, defaultBranch, emptyStateIllustrationPath, + helpPaths, lintHelpPagePath, + needsHelpPagePath, newMergeRequestPath, + pipelinePagePath, projectFullPath, projectPath, projectNamespace, + runnerHelpPagePath, + totalBranches: parseInt(totalBranches, 10), ymlHelpPagePath, - configurationPaths, }, render(h) { return h(PipelineEditorApp); diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index e0fb38004ec..79a2a51cebc 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,21 +1,15 @@ <script> -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { fetchPolicies } from '~/lib/graphql'; import httpStatusCodes from '~/lib/utils/http_status'; -import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; -import CodeSnippetAlert from './components/code_snippet_alert/code_snippet_alert.vue'; -import { - CODE_SNIPPET_SOURCE_URL_PARAM, - CODE_SNIPPET_SOURCES, -} from './components/code_snippet_alert/constants'; + import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; +import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue'; import { - COMMIT_FAILURE, - COMMIT_SUCCESS, - DEFAULT_FAILURE, EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_ERROR, EDITOR_APP_STATUS_LOADING, @@ -31,11 +25,10 @@ import PipelineEditorHome from './pipeline_editor_home.vue'; export default { components: { ConfirmUnsavedChangesDialog, - GlAlert, GlLoadingIcon, PipelineEditorEmptyState, PipelineEditorHome, - CodeSnippetAlert, + PipelineEditorMessages, }, inject: { ciConfigPath: { @@ -50,20 +43,20 @@ export default { ciConfigData: {}, failureType: null, failureReasons: [], - showStartScreen: false, - isNewCiConfigFile: false, initialCiFileContent: '', + isNewCiConfigFile: false, lastCommittedContent: '', currentCiFileContent: '', - showFailureAlert: false, - showSuccessAlert: false, successType: null, - codeSnippetCopiedFrom: '', + showStartScreen: false, + showSuccess: false, + showFailure: false, }; }, apollo: { initialCiFileContent: { + fetchPolicy: fetchPolicies.NETWORK, query: getBlobContent, // If it's a brand new file, we don't want to fetch the content. // Then when the user commits the first time, the query would run @@ -87,10 +80,21 @@ export default { this.lastCommittedContent = fileContent; this.currentCiFileContent = fileContent; + + // make sure to reset the start screen flag during a refetch + // e.g. when switching branches + if (fileContent.length) { + this.showStartScreen = false; + } }, error(error) { this.handleBlobContentError(error); }, + watchLoading(isLoading) { + if (isLoading) { + this.setAppStatus(EDITOR_APP_STATUS_LOADING); + } + }, }, ciConfigData: { query: getCiConfigData, @@ -145,50 +149,12 @@ export default { isEmpty() { return this.currentCiFileContent === ''; }, - failure() { - switch (this.failureType) { - case LOAD_FAILURE_UNKNOWN: - return { - text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN], - variant: 'danger', - }; - case COMMIT_FAILURE: - return { - text: this.$options.errorTexts[COMMIT_FAILURE], - variant: 'danger', - }; - default: - return { - text: this.$options.errorTexts[DEFAULT_FAILURE], - variant: 'danger', - }; - } - }, - success() { - switch (this.successType) { - case COMMIT_SUCCESS: - return { - text: this.$options.successTexts[COMMIT_SUCCESS], - variant: 'info', - }; - default: - return null; - } - }, }, i18n: { tabEdit: s__('Pipelines|Write pipeline configuration'), tabGraph: s__('Pipelines|Visualize'), tabLint: s__('Pipelines|Lint'), }, - errorTexts: { - [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), - [DEFAULT_FAILURE]: __('Something went wrong on our end.'), - [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), - }, - successTexts: { - [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), - }, watch: { isEmpty(flag) { if (flag) { @@ -196,9 +162,6 @@ export default { } }, }, - created() { - this.parseCodeSnippetSourceParam(); - }, methods: { handleBlobContentError(error = {}) { const { networkError } = error; @@ -216,24 +179,27 @@ export default { this.reportFailure(LOAD_FAILURE_UNKNOWN); } }, - - dismissFailure() { - this.showFailureAlert = false; + hideFailure() { + this.showFailure = false; + }, + hideSuccess() { + this.showSuccess = false; }, - dismissSuccess() { - this.showSuccessAlert = false; + async refetchContent() { + this.$apollo.queries.initialCiFileContent.skip = false; + await this.$apollo.queries.initialCiFileContent.refetch(); }, reportFailure(type, reasons = []) { this.setAppStatus(EDITOR_APP_STATUS_ERROR); window.scrollTo({ top: 0, behavior: 'smooth' }); - this.showFailureAlert = true; + this.showFailure = true; this.failureType = type; this.failureReasons = reasons; }, reportSuccess(type) { window.scrollTo({ top: 0, behavior: 'smooth' }); - this.showSuccessAlert = true; + this.showSuccess = true; this.successType = type; }, resetContent() { @@ -266,20 +232,6 @@ export default { // if the user has made changes to the file that are unsaved. this.lastCommittedContent = this.currentCiFileContent; }, - parseCodeSnippetSourceParam() { - const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM); - if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) { - this.codeSnippetCopiedFrom = codeSnippetCopiedFrom; - window.history.replaceState( - {}, - document.title, - removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]), - ); - } - }, - dismissCodeSnippetAlert() { - this.codeSnippetCopiedFrom = ''; - }, }, }; </script> @@ -290,33 +242,18 @@ export default { <pipeline-editor-empty-state v-else-if="showStartScreen" @createEmptyConfigFile="setNewEmptyCiConfigFile" + @refetchContent="refetchContent" /> <div v-else> - <code-snippet-alert - v-if="codeSnippetCopiedFrom" - :source="codeSnippetCopiedFrom" - class="gl-mb-5" - @dismiss="dismissCodeSnippetAlert" + <pipeline-editor-messages + :failure-type="failureType" + :failure-reasons="failureReasons" + :show-failure="showFailure" + :show-success="showSuccess" + :success-type="successType" + @hide-success="hideSuccess" + @hide-failure="hideFailure" /> - <gl-alert - v-if="showSuccessAlert" - :variant="success.variant" - class="gl-mb-5" - @dismiss="dismissSuccess" - > - {{ success.text }} - </gl-alert> - <gl-alert - v-if="showFailureAlert" - :variant="failure.variant" - class="gl-mb-5" - @dismiss="dismissFailure" - > - {{ failure.text }} - <ul v-if="failureReasons.length" class="gl-mb-0"> - <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li> - </ul> - </gl-alert> <pipeline-editor-home :ci-config-data="ciConfigData" :ci-file-content="currentCiFileContent" @@ -324,6 +261,7 @@ export default { @commit="updateOnCommit" @resetContent="resetContent" @showError="showErrorAlert" + @refetchContent="refetchContent" @updateCiConfig="updateCiConfig" /> <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" /> diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index adba55f9f4b..dfe9c82b912 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -1,5 +1,7 @@ <script> +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import CommitSection from './components/commit/commit_section.vue'; +import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue'; import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue'; import PipelineEditorHeader from './components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from './components/pipeline_editor_tabs.vue'; @@ -8,10 +10,12 @@ import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants'; export default { components: { CommitSection, + PipelineEditorDrawer, PipelineEditorFileNav, PipelineEditorHeader, PipelineEditorTabs, }, + mixins: [glFeatureFlagMixin()], props: { ciConfigData: { type: Object, @@ -35,6 +39,9 @@ export default { showCommitForm() { return TABS_WITH_COMMIT_FORM.includes(this.currentTab); }, + showPipelineDrawer() { + return this.glFeatures.pipelineEditorDrawer; + }, }, methods: { setCurrentTab(tabName) { @@ -45,7 +52,7 @@ export default { </script> <template> - <div> + <div class="gl-pr-9 gl-transition-medium gl-w-full"> <pipeline-editor-file-nav v-on="$listeners" /> <pipeline-editor-header :ci-config-data="ciConfigData" @@ -58,5 +65,6 @@ export default { @set-current-tab="setCurrentTab" /> <commit-section v-if="showCommitForm" :ci-file-content="ciFileContent" v-on="$listeners" /> + <pipeline-editor-drawer v-if="showPipelineDrawer" /> </div> </template> diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index e44d80ee9d1..5472e51445a 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -21,7 +21,13 @@ import { backOff } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; -import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants'; +import { + VARIABLE_TYPE, + FILE_TYPE, + CONFIG_VARIABLES_TIMEOUT, + CC_VALIDATION_REQUIRED_ERROR, +} from '../constants'; +import filterVariables from '../utils/filter_variables'; import RefsDropdown from './refs_dropdown.vue'; const i18n = { @@ -59,6 +65,8 @@ export default { GlSprintf, GlLoadingIcon, RefsDropdown, + CcValidationRequiredAlert: () => + import('ee_component/billings/components/cc_validation_required_alert.vue'), }, directives: { SafeHtml }, props: { @@ -142,6 +150,9 @@ export default { descriptions() { return this.form[this.refFullName]?.descriptions ?? {}; }, + ccRequiredError() { + return this.error === CC_VALIDATION_REQUIRED_ERROR; + }, }, watch: { refValue() { @@ -281,20 +292,13 @@ export default { }, createPipeline() { this.submitted = true; - const filteredVariables = this.variables - .filter(({ key, value }) => key !== '' && value !== '') - .map(({ variable_type, key, value }) => ({ - variable_type, - key, - secret_value: value, - })); return axios .post(this.pipelinesPath, { // send shortName as fall back for query params // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 ref: this.refValue.fullName || this.refShortName, - variables_attributes: filteredVariables, + variables_attributes: filterVariables(this.variables), }) .then(({ data }) => { redirectTo(`${this.pipelinesPath}/${data.id}`); @@ -335,8 +339,9 @@ export default { <template> <gl-form @submit.prevent="createPipeline"> + <cc-validation-required-alert v-if="ccRequiredError" class="gl-pb-5" /> <gl-alert - v-if="error" + v-else-if="error" :title="errorTitle" :dismissible="false" variant="danger" @@ -393,6 +398,7 @@ export default { v-model="variable.variable_type" :class="$options.formElementClasses" :options="$options.typeOptions" + data-testid="pipeline-form-ci-variable-type" /> <gl-form-input v-model="variable.key" diff --git a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue index ed5c659d1df..d35d2010150 100644 --- a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue +++ b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue @@ -81,11 +81,12 @@ export default { }; </script> <template> - <gl-dropdown :text="refShortName" block @show.once="loadRefs"> + <gl-dropdown :text="refShortName" block data-testid="ref-select" @show.once="loadRefs"> <gl-search-box-by-type v-model.trim="searchTerm" :is-loading="isLoading" :placeholder="__('Search refs')" + data-testid="search-refs" /> <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header> <gl-dropdown-item diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js index 681755dc6ab..91a064a0fb8 100644 --- a/app/assets/javascripts/pipeline_new/constants.js +++ b/app/assets/javascripts/pipeline_new/constants.js @@ -4,3 +4,6 @@ export const DEBOUNCE_REFS_SEARCH_MS = 250; export const CONFIG_VARIABLES_TIMEOUT = 5000; export const BRANCH_REF_TYPE = 'branch'; export const TAG_REF_TYPE = 'tag'; + +export const CC_VALIDATION_REQUIRED_ERROR = + 'Credit card required to be on file in order to create a pipeline'; diff --git a/app/assets/javascripts/pipeline_new/utils/filter_variables.js b/app/assets/javascripts/pipeline_new/utils/filter_variables.js new file mode 100644 index 00000000000..57ce3d13a9a --- /dev/null +++ b/app/assets/javascripts/pipeline_new/utils/filter_variables.js @@ -0,0 +1,13 @@ +// We need to filter out blank variables +// and filter out variables that have no key +// before sending to the API to create a pipeline. + +export default (variables) => { + return variables + .filter(({ key }) => key !== '') + .map(({ variable_type, key, value }) => ({ + variable_type, + key, + secret_value: value, + })); +}; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 63048777724..71ec81b8969 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -2,6 +2,7 @@ import { reportToSentry } from '../../utils'; import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; import LinksLayer from '../graph_shared/links_layer.vue'; +import { generateColumnsFromLayersListMemoized } from '../parsing_utils'; import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import StageColumnComponent from './stage_column_component.vue'; @@ -25,6 +26,10 @@ export default { type: Object, required: true, }, + showLinks: { + type: Boolean, + required: true, + }, viewType: { type: String, required: true, @@ -74,7 +79,9 @@ export default { return this.hasDownstreamPipelines ? this.pipeline.downstream : []; }, layout() { - return this.isStageView ? this.pipeline.stages : this.generateColumnsFromLayersList(); + return this.isStageView + ? this.pipeline.stages + : generateColumnsFromLayersListMemoized(this.pipeline, this.pipelineLayers); }, hasDownstreamPipelines() { return Boolean(this.pipeline?.downstream?.length > 0); @@ -91,8 +98,8 @@ export default { collectMetrics: true, }; }, - shouldHideLinks() { - return this.isStageView; + showJobLinks() { + return !this.isStageView && this.showLinks; }, shouldShowStageName() { return !this.isStageView; @@ -120,26 +127,6 @@ export default { this.getMeasurements(); }, methods: { - generateColumnsFromLayersList() { - return this.pipelineLayers.map((layers, idx) => { - /* - look up the groups in each layer, - then add each set of layer groups to a stage-like object - */ - - const groups = layers.map((id) => { - const { stageIdx, groupIdx } = this.pipeline.stagesLookup[id]; - return this.pipeline.stages?.[stageIdx]?.groups?.[groupIdx]; - }); - - return { - name: '', - id: `layer-${idx}`, - status: { action: null }, - groups: groups.filter(Boolean), - }; - }); - }, getMeasurements() { this.measurements = { width: this.$refs[this.containerId].scrollWidth, @@ -178,7 +165,7 @@ export default { <div class="js-pipeline-graph"> <div ref="mainPipelineContainer" - class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap" + class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap gl-border-t-solid gl-border-t-1 gl-border-gray-100" :class="{ 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline }" > <linked-graph-wrapper> @@ -188,6 +175,7 @@ export default { :config-paths="configPaths" :linked-pipelines="upstreamPipelines" :column-title="__('Upstream')" + :show-links="showJobLinks" :type="$options.pipelineTypeConstants.UPSTREAM" :view-type="viewType" @error="onError" @@ -202,9 +190,8 @@ export default { :container-measurements="measurements" :highlighted-job="hoveredJobName" :metrics-config="metricsConfig" - :never-show-links="shouldHideLinks" + :show-links="showJobLinks" :view-type="viewType" - default-link-color="gl-stroke-transparent" @error="onError" @highlightedJobsChange="updateHighlightedJobs" > @@ -234,6 +221,7 @@ export default { :config-paths="configPaths" :linked-pipelines="downstreamPipelines" :column-title="__('Downstream')" + :show-links="showJobLinks" :type="$options.pipelineTypeConstants.DOWNSTREAM" :view-type="viewType" @downstreamHovered="setSourceJob" diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index 0bc6d883245..9329a35ba99 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -5,7 +5,9 @@ import { __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; -import { reportToSentry } from '../../utils'; +import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; +import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql'; +import { reportToSentry, reportMessageToSentry } from '../../utils'; import { listByLayers } from '../parsing_utils'; import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; import PipelineGraph from './graph_component.vue'; @@ -17,6 +19,9 @@ import { unwrapPipelineData, } from './utils'; +const featureName = 'pipeline_needs_hover_tip'; +const enumFeatureName = featureName.toUpperCase(); + export default { name: 'PipelineGraphWrapper', components: { @@ -44,10 +49,12 @@ export default { data() { return { alertType: null, + callouts: [], currentViewType: STAGE_VIEW, pipeline: null, pipelineLayers: null, showAlert: false, + showLinks: false, }; }, errorTexts: { @@ -59,6 +66,18 @@ export default { [DEFAULT]: __('An unknown error occurred while loading this graph.'), }, apollo: { + callouts: { + query: getUserCallouts, + update(data) { + return data?.currentUser?.callouts?.nodes.map((callout) => callout.featureName) || []; + }, + error(err) { + reportToSentry( + this.$options.name, + `type: callout_load_failure, info: ${serializeLoadErrors(err)}`, + ); + }, + }, pipeline: { context() { return getQueryHeaders(this.graphqlResourceEtag); @@ -90,9 +109,16 @@ export default { }, error(err) { this.reportFailure({ type: LOAD_FAILURE, skipSentry: true }); - reportToSentry( + + reportMessageToSentry( this.$options.name, - `type: ${LOAD_FAILURE}, info: ${serializeLoadErrors(err)}`, + `| type: ${LOAD_FAILURE} , info: ${serializeLoadErrors(err)}`, + { + projectPath: this.projectPath, + pipelineIid: this.pipelineIid, + pipelineStages: this.pipeline?.stages?.length || 0, + nbOfDownstreams: this.pipeline?.downstream?.length || 0, + }, ); }, result({ error }) { @@ -137,6 +163,13 @@ export default { metricsPath: this.metricsPath, }; }, + graphViewType() { + /* This prevents reading view type off the localStorage value if it does not apply. */ + return this.showGraphViewSelector ? this.currentViewType : STAGE_VIEW; + }, + hoverTipPreviouslyDismissed() { + return this.callouts.includes(enumFeatureName); + }, showLoadingIcon() { /* Shows the icon only when the graph is empty, not when it is is @@ -166,6 +199,18 @@ export default { return this.pipelineLayers; }, + handleTipDismissal() { + try { + this.$apollo.mutate({ + mutation: DismissPipelineGraphCallout, + variables: { + featureName, + }, + }); + } catch (err) { + reportToSentry(this.$options.name, `type: callout_dismiss_failure, info: ${err}`); + } + }, hideAlert() { this.showAlert = false; this.alertType = null; @@ -182,6 +227,9 @@ export default { } }, /* eslint-enable @gitlab/require-i18n-strings */ + updateShowLinksState(val) { + this.showLinks = val; + }, updateViewType(type) { this.currentViewType = type; }, @@ -201,8 +249,12 @@ export default { > <graph-view-selector v-if="showGraphViewSelector" - :type="currentViewType" + :type="graphViewType" + :show-links="showLinks" + :tip-previously-dismissed="hoverTipPreviouslyDismissed" + @dismissHoverTip="handleTipDismissal" @updateViewType="updateViewType" + @updateShowLinksState="updateShowLinksState" /> </local-storage-sync> <gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" /> @@ -211,7 +263,8 @@ export default { :config-paths="configPaths" :pipeline="pipeline" :pipeline-layers="getPipelineLayers()" - :view-type="currentViewType" + :show-links="showLinks" + :view-type="graphViewType" @error="reportFailure" @refreshPipelineGraph="refreshPipelineGraph" /> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue index f33e6290e37..1435276edd3 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue @@ -1,17 +1,25 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlSegmentedControl, GlToggle } from '@gitlab/ui'; import { __ } from '~/locale'; import { STAGE_VIEW, LAYER_VIEW } from './constants'; export default { name: 'GraphViewSelector', components: { - GlDropdown, - GlDropdownItem, - GlIcon, - GlSprintf, + GlAlert, + GlLoadingIcon, + GlSegmentedControl, + GlToggle, }, props: { + showLinks: { + type: Boolean, + required: true, + }, + tipPreviouslyDismissed: { + type: Boolean, + required: true, + }, type: { type: String, required: true, @@ -19,67 +27,138 @@ export default { }, data() { return { - currentViewType: STAGE_VIEW, + hoverTipDismissed: false, + isToggleLoading: false, + isSwitcherLoading: false, + segmentSelectedType: this.type, + showLinksActive: false, }; }, i18n: { - labelText: __('Order jobs by'), + hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'), + linksLabelText: __('Show dependencies'), + viewLabelText: __('Group jobs by'), }, views: { [STAGE_VIEW]: { type: STAGE_VIEW, text: { primary: __('Stage'), - secondary: __('View the jobs grouped into stages'), }, }, [LAYER_VIEW]: { type: LAYER_VIEW, text: { - primary: __('%{codeStart}needs:%{codeEnd} relationships'), - secondary: __('View what jobs are needed for a job to run'), + primary: __('Job dependencies'), }, }, }, computed: { - currentDropdownText() { - return this.$options.views[this.type].text.primary; + showLinksToggle() { + return this.segmentSelectedType === LAYER_VIEW; + }, + showTip() { + return ( + this.showLinks && + this.showLinksActive && + !this.tipPreviouslyDismissed && + !this.hoverTipDismissed + ); + }, + viewTypesList() { + return Object.keys(this.$options.views).map((key) => { + return { + value: key, + text: this.$options.views[key].text.primary, + }; + }); + }, + }, + watch: { + /* + How does this reset the loading? As we note in the methods comment below, + the loader is set to on before the update work is undertaken (in the parent). + Once the work is complete, one of these values will change, since that's the + point of the work. When that happens, the related value will update and we are done. + + The bonus for this approach is that it works the same whichever "direction" + the work goes in. + */ + showLinks() { + this.isToggleLoading = false; + }, + type() { + this.isSwitcherLoading = false; }, }, methods: { - itemClick(type) { - this.$emit('updateViewType', type); + dismissTip() { + this.hoverTipDismissed = true; + this.$emit('dismissHoverTip'); + }, + /* + In both toggle methods, we use setTimeout so that the loading indicator displays, + then the work is done to update the DOM. The process is: + → user clicks + → call stack: set loading to true + → render: the loading icon appears on the screen + → callback queue: now do the work to calculate the new view / links + (note: this work is done in the parent after the event is emitted) + + setTimeout is how we move the work to the callback queue. + We can't use nextTick because that is called before the render loop. + + See https://www.hesselinkwebdesign.nl/2019/nexttick-vs-settimeout-in-vue/ for more details. + */ + toggleView(type) { + this.isSwitcherLoading = true; + setTimeout(() => { + this.$emit('updateViewType', type); + }); + }, + toggleShowLinksActive(val) { + this.isToggleLoading = true; + setTimeout(() => { + this.$emit('updateShowLinksState', val); + }); }, }, }; </script> <template> - <div class="gl-display-flex gl-align-items-center gl-my-4"> - <span>{{ $options.i18n.labelText }}</span> - <gl-dropdown data-testid="pipeline-view-selector" class="gl-ml-4"> - <template #button-content> - <gl-sprintf :message="currentDropdownText"> - <template #code="{ content }"> - <code> {{ content }} </code> - </template> - </gl-sprintf> - <gl-icon class="gl-px-2" name="angle-down" :size="16" /> - </template> - <gl-dropdown-item - v-for="view in $options.views" - :key="view.type" - :secondary-text="view.text.secondary" - @click="itemClick(view.type)" - > - <b> - <gl-sprintf :message="view.text.primary"> - <template #code="{ content }"> - <code> {{ content }} </code> - </template> - </gl-sprintf> - </b> - </gl-dropdown-item> - </gl-dropdown> + <div> + <div class="gl-relative gl-display-flex gl-align-items-center gl-w-max-content gl-my-4"> + <gl-loading-icon + v-if="isSwitcherLoading" + data-testid="switcher-loading-state" + class="gl-absolute gl-w-full gl-bg-white gl-opacity-5 gl-z-index-2" + size="lg" + /> + <span class="gl-font-weight-bold">{{ $options.i18n.viewLabelText }}</span> + <gl-segmented-control + v-model="segmentSelectedType" + :options="viewTypesList" + :disabled="isSwitcherLoading" + data-testid="pipeline-view-selector" + class="gl-mx-4" + @input="toggleView" + /> + + <div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center"> + <gl-toggle + v-model="showLinksActive" + data-testid="show-links-toggle" + class="gl-mx-4" + :label="$options.i18n.linksLabelText" + :is-loading="isToggleLoading" + label-position="left" + @change="toggleShowLinksActive" + /> + </div> + </div> + <gl-alert v-if="showTip" class="gl-my-5" variant="tip" @dismiss="dismissTip"> + {{ $options.i18n.hoverTipText }} + </gl-alert> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 6451605a222..b2a3f27e079 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -53,6 +53,7 @@ export default { }; </script> <template> + <!-- eslint-disable @gitlab/vue-no-data-toggle --> <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright"> <button type="button" diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 7f772e35e55..45113ecff41 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -3,7 +3,7 @@ import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.qu import { LOAD_FAILURE } from '../../constants'; import { reportToSentry } from '../../utils'; import { listByLayers } from '../parsing_utils'; -import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW } from './constants'; +import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW, STAGE_VIEW } from './constants'; import LinkedPipeline from './linked_pipeline.vue'; import { getQueryHeaders, @@ -32,6 +32,10 @@ export default { type: Array, required: true, }, + showLinks: { + type: Boolean, + required: true, + }, type: { type: String, required: true, @@ -76,6 +80,9 @@ export default { graphPosition() { return this.isUpstream ? 'left' : 'right'; }, + graphViewType() { + return this.currentPipeline?.usesNeeds ? this.viewType : STAGE_VIEW; + }, isUpstream() { return this.type === UPSTREAM; }, @@ -217,8 +224,9 @@ export default { :config-paths="configPaths" :pipeline="currentPipeline" :pipeline-layers="getPipelineLayers(pipeline.id)" + :show-links="showLinks" :is-linked-pipeline="true" - :view-type="viewType" + :view-type="graphViewType" /> </div> </li> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index fa2f381c8a4..81d59f1ef65 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -160,7 +160,10 @@ export default { :pipeline-id="pipelineId" :stage-name="showStageName ? group.stageName : ''" css-class-job-name="gl-build-content" - :class="{ 'gl-opacity-3': isFadedOut(group.name) }" + :class="[ + { 'gl-opacity-3': isFadedOut(group.name) }, + 'gl-transition-duration-slow gl-transition-timing-function-ease', + ]" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" /> <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js index 373aa6bf9a1..163b3898c28 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -1,3 +1,4 @@ +import { isEmpty } from 'lodash'; import Visibility from 'visibilityjs'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils'; @@ -39,15 +40,15 @@ const serializeGqlErr = (gqlError) => { const serializeLoadErrors = (errors) => { const { gqlError, graphQLErrors, networkError, message } = errors; - if (graphQLErrors) { + if (!isEmpty(graphQLErrors)) { return graphQLErrors.map((err) => serializeGqlErr(err)).join('; '); } - if (gqlError) { + if (!isEmpty(gqlError)) { return serializeGqlErr(gqlError); } - if (networkError) { + if (!isEmpty(networkError)) { return `Network error: ${networkError.message}`; } diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js index 49cd04d11e9..0fe7d9ffda3 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/api.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js @@ -2,6 +2,11 @@ import axios from '~/lib/utils/axios_utils'; import { reportToSentry } from '../../utils'; export const reportPerformance = (path, stats) => { + // FIXME: https://gitlab.com/gitlab-org/gitlab/-/issues/330245 + if (!path) { + return; + } + axios.post(path, stats).catch((err) => { reportToSentry('links_inner_perf', `error: ${err}`); }); diff --git a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js index 202498fb188..7c306683305 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js @@ -15,6 +15,7 @@ export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobNam export const generateLinksData = ({ links }, containerID, modifier = '') => { const containerEl = document.getElementById(containerID); + return links.map((link) => { const path = d3.path(); diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue index 0ed5b8a5f09..5c775df7b48 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue @@ -1,19 +1,8 @@ <script> import { isEmpty } from 'lodash'; -import { - PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, - PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, - PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - PIPELINES_DETAIL_LINK_DURATION, - PIPELINES_DETAIL_LINKS_TOTAL, - PIPELINES_DETAIL_LINKS_JOB_RATIO, -} from '~/performance/constants'; -import { performanceMarkAndMeasure } from '~/performance/utils'; import { DRAW_FAILURE } from '../../constants'; import { createJobsHash, generateJobNeedsDict, reportToSentry } from '../../utils'; import { STAGE_VIEW } from '../graph/constants'; -import { parseData } from '../parsing_utils'; -import { reportPerformance } from './api'; import { generateLinksData } from './drawing_utils'; export default { @@ -28,6 +17,10 @@ export default { type: Object, required: true, }, + parsedData: { + type: Object, + required: true, + }, pipelineId: { type: Number, required: true, @@ -36,15 +29,6 @@ export default { type: Array, required: true, }, - totalGroups: { - type: Number, - required: true, - }, - metricsConfig: { - type: Object, - required: false, - default: () => ({}), - }, defaultLinkColor: { type: String, required: false, @@ -65,13 +49,9 @@ export default { return { links: [], needsObject: null, - parsedData: {}, }; }, computed: { - shouldCollectMetrics() { - return this.metricsConfig.collectMetrics && this.metricsConfig.path; - }, hasHighlightedJob() { return Boolean(this.highlightedJob); }, @@ -115,13 +95,16 @@ export default { highlightedJobs(jobs) { this.$emit('highlightedJobsChange', jobs); }, + parsedData() { + this.calculateLinkData(); + }, viewType() { /* We need to wait a tick so that the layout reflows before the links refresh. */ this.$nextTick(() => { - this.refreshLinks(); + this.calculateLinkData(); }); }, }, @@ -129,69 +112,21 @@ export default { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, mounted() { - if (!isEmpty(this.pipelineData)) { - this.prepareLinkData(); + if (!isEmpty(this.parsedData)) { + this.calculateLinkData(); } }, methods: { - beginPerfMeasure() { - if (this.shouldCollectMetrics) { - performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START }); - } - }, - finishPerfMeasureAndSend() { - if (this.shouldCollectMetrics) { - performanceMarkAndMeasure({ - mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, - measures: [ - { - name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, - }, - ], - }); - } - - window.requestAnimationFrame(() => { - const duration = window.performance.getEntriesByName( - PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - )[0]?.duration; - - if (!duration) { - return; - } - - const data = { - histograms: [ - { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, - { name: PIPELINES_DETAIL_LINKS_TOTAL, value: this.links.length }, - { - name: PIPELINES_DETAIL_LINKS_JOB_RATIO, - value: this.links.length / this.totalGroups, - }, - ], - }; - - reportPerformance(this.metricsConfig.path, data); - }); - }, isLinkHighlighted(linkRef) { return this.highlightedLinks.includes(linkRef); }, - prepareLinkData() { - this.beginPerfMeasure(); + calculateLinkData() { try { - const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); - this.parsedData = parseData(arrayOfJobs); - this.refreshLinks(); + this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`); } catch (err) { this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false }); reportToSentry(this.$options.name, err); } - this.finishPerfMeasureAndSend(); - }, - refreshLinks() { - this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`); }, getLinkClasses(link) { return [ diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue index 8dbab245f44..81409752621 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue @@ -1,5 +1,4 @@ <script> -import { GlAlert } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { __ } from '~/locale'; import { @@ -19,10 +18,8 @@ import LinksInner from './links_inner.vue'; export default { name: 'LinksLayer', components: { - GlAlert, LinksInner, }, - MAX_GROUPS: 200, props: { containerMeasurements: { type: Object, @@ -37,15 +34,16 @@ export default { required: false, default: () => ({}), }, - neverShowLinks: { + showLinks: { type: Boolean, required: false, - default: false, + default: true, }, }, data() { return { alertDismissed: false, + parsedData: {}, showLinksOverride: false, }; }, @@ -67,43 +65,15 @@ export default { shouldCollectMetrics() { return this.metricsConfig.collectMetrics && this.metricsConfig.path; }, - showAlert() { - /* - This is a hard override that allows us to turn off the links without - needing to remove the component entirely for iteration or based on graph type. - */ - if (this.neverShowLinks) { - return false; - } - - return !this.containerZero && !this.showLinkedLayers && !this.alertDismissed; - }, showLinkedLayers() { - /* - This is a hard override that allows us to turn off the links without - needing to remove the component entirely for iteration or based on graph type. - */ - if (this.neverShowLinks) { - return false; - } - - return ( - !this.containerZero && (this.showLinksOverride || this.numGroups < this.$options.MAX_GROUPS) - ); + return this.showLinks && !this.containerZero; }, }, errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, mounted() { - /* - This is code to get metrics for the graph (to observe links performance). - It is currently here because we want values for links without drawing them. - It can be removed when https://gitlab.com/gitlab-org/gitlab/-/issues/298930 - is closed and functionality is enabled by default. - */ - - if (this.neverShowLinks && !isEmpty(this.pipelineData)) { + if (!isEmpty(this.pipelineData)) { window.requestAnimationFrame(() => { this.prepareLinkData(); }); @@ -151,19 +121,13 @@ export default { reportPerformance(this.metricsConfig.path, data); }); }, - dismissAlert() { - this.alertDismissed = true; - }, - overrideShowLinks() { - this.dismissAlert(); - this.showLinksOverride = true; - }, prepareLinkData() { this.beginPerfMeasure(); let numLinks; try { const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); - numLinks = parseData(arrayOfJobs).links.length; + this.parsedData = parseData(arrayOfJobs); + numLinks = this.parsedData.links.length; } catch (err) { reportToSentry(this.$options.name, err); } @@ -176,24 +140,15 @@ export default { <links-inner v-if="showLinkedLayers" :container-measurements="containerMeasurements" + :parsed-data="parsedData" :pipeline-data="pipelineData" :total-groups="numGroups" - :metrics-config="metricsConfig" v-bind="$attrs" v-on="$listeners" > <slot></slot> </links-inner> <div v-else> - <gl-alert - v-if="showAlert" - class="gl-ml-4 gl-mb-4" - :primary-button-text="$options.i18n.showLinksAnyways" - @primaryAction="overrideShowLinks" - @dismiss="dismissAlert" - > - {{ $options.i18n.tooManyJobs }} - </gl-alert> <div class="gl-display-flex gl-relative"> <slot></slot> </div> diff --git a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue index 6982586ab12..6dff3828a34 100644 --- a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue +++ b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue @@ -2,7 +2,7 @@ import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import DismissPipelineNotification from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; +import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql'; const featureName = 'pipeline_needs_banner'; @@ -55,7 +55,7 @@ export default { this.dismissedAlert = true; try { this.$apollo.mutate({ - mutation: DismissPipelineNotification, + mutation: DismissPipelineGraphCallout, variables: { featureName, }, diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index f5ab869633b..9d886e0e379 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -1,4 +1,4 @@ -import { uniqWith, isEqual } from 'lodash'; +import { isEqual, memoize, uniqWith } from 'lodash'; import { createSankey } from './dag/drawing_utils'; /* @@ -170,3 +170,26 @@ export const listByLayers = ({ stages }) => { return acc; }, []); }; + +export const generateColumnsFromLayersListBare = ({ stages, stagesLookup }, pipelineLayers) => { + return pipelineLayers.map((layers, idx) => { + /* + Look up the groups in each layer, + then add each set of layer groups to a stage-like object. + */ + + const groups = layers.map((id) => { + const { stageIdx, groupIdx } = stagesLookup[id]; + return stages[stageIdx]?.groups?.[groupIdx]; + }); + + return { + name: '', + id: `layer-${idx}`, + status: { action: null }, + groups: groups.filter(Boolean), + }; + }); +}; + +export const generateColumnsFromLayersListMemoized = memoize(generateColumnsFromLayersListBare); diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index c3bcfcb18fb..e9773f055a7 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -1,6 +1,8 @@ <script> -import { GlEmptyState } from '@gitlab/ui'; -import Experiment from '~/experimentation/components/experiment.vue'; +import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { startCodeQualityWalkthrough, track } from '~/code_quality_walkthrough/utils'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; +import { getExperimentData } from '~/experimentation/utils'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; import PipelinesCiTemplates from './pipelines_ci_templates.vue'; @@ -12,12 +14,18 @@ export default { test, and deploy your code. Let GitLab take care of time consuming tasks, so you can spend more time creating.`), btnText: s__('Pipelines|Get started with CI/CD'), + codeQualityTitle: s__('Pipelines|Improve code quality with GitLab CI/CD'), + codeQualityDescription: s__(`Pipelines|To keep your codebase simple, + readable, and accessible to contributors, use GitLab CI/CD + to analyze your code quality with every push to your project.`), + codeQualityBtnText: s__('Pipelines|Add a code quality job'), noCiDescription: s__('Pipelines|This project is not currently set up to run pipelines.'), }, name: 'PipelinesEmptyState', components: { GlEmptyState, - Experiment, + GlButton, + GitlabExperiment, PipelinesCiTemplates, }, props: { @@ -29,36 +37,82 @@ export default { type: Boolean, required: true, }, + codeQualityPagePath: { + type: String, + required: false, + default: null, + }, }, computed: { ciHelpPagePath() { return helpPagePath('ci/quick_start/index.md'); }, + isPipelineEmptyStateTemplatesExperimentActive() { + return this.canSetCi && Boolean(getExperimentData('pipeline_empty_state_templates')); + }, + }, + mounted() { + startCodeQualityWalkthrough(); + }, + methods: { + trackClick() { + track('cta_clicked'); + }, }, }; </script> <template> <div> - <experiment name="pipeline_empty_state_templates"> + <gitlab-experiment + v-if="isPipelineEmptyStateTemplatesExperimentActive" + name="pipeline_empty_state_templates" + > <template #control> <gl-empty-state - v-if="canSetCi" :title="$options.i18n.title" :svg-path="emptyStateSvgPath" :description="$options.i18n.description" :primary-button-text="$options.i18n.btnText" :primary-button-link="ciHelpPagePath" /> + </template> + <template #candidate> + <pipelines-ci-templates /> + </template> + </gitlab-experiment> + <gitlab-experiment v-else-if="canSetCi" name="code_quality_walkthrough"> + <template #control> <gl-empty-state - v-else - title="" + :title="$options.i18n.title" :svg-path="emptyStateSvgPath" - :description="$options.i18n.noCiDescription" - /> + :description="$options.i18n.description" + > + <template #actions> + <gl-button :href="ciHelpPagePath" variant="confirm" @click="trackClick()"> + {{ $options.i18n.btnText }} + </gl-button> + </template> + </gl-empty-state> </template> <template #candidate> - <pipelines-ci-templates /> + <gl-empty-state + :title="$options.i18n.codeQualityTitle" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.codeQualityDescription" + > + <template #actions> + <gl-button :href="codeQualityPagePath" variant="confirm" @click="trackClick()"> + {{ $options.i18n.codeQualityBtnText }} + </gl-button> + </template> + </gl-empty-state> </template> - </experiment> + </gitlab-experiment> + <gl-empty-state + v-else + title="" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.noCiDescription" + /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue new file mode 100644 index 00000000000..d7bd2d731b1 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue @@ -0,0 +1,115 @@ +<script> +import { + GlAlert, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlLoadingIcon, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __, s__ } from '~/locale'; + +export const i18n = { + artifacts: __('Artifacts'), + downloadArtifact: __('Download %{name} artifact'), + artifactSectionHeader: __('Download artifacts'), + artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), +}; + +export default { + i18n, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlAlert, + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlLoadingIcon, + GlSprintf, + }, + inject: { + artifactsEndpoint: { + default: '', + }, + artifactsEndpointPlaceholder: { + default: '', + }, + }, + props: { + pipelineId: { + type: Number, + required: true, + }, + }, + data() { + return { + artifacts: [], + hasError: false, + isLoading: false, + }; + }, + methods: { + fetchArtifacts() { + this.isLoading = true; + // Replace the placeholder with the ID of the pipeline we are viewing + const endpoint = this.artifactsEndpoint.replace( + this.artifactsEndpointPlaceholder, + this.pipelineId, + ); + return axios + .get(endpoint) + .then(({ data }) => { + this.artifacts = data.artifacts; + }) + .catch(() => { + this.hasError = true; + }) + .finally(() => { + this.isLoading = false; + }); + }, + }, +}; +</script> +<template> + <gl-dropdown + v-gl-tooltip + :title="$options.i18n.artifacts" + :text="$options.i18n.artifacts" + :aria-label="$options.i18n.artifacts" + icon="ellipsis_v" + data-testid="pipeline-multi-actions-dropdown" + right + lazy + text-sr-only + no-caret + @show.once="fetchArtifacts" + > + <gl-dropdown-section-header>{{ + $options.i18n.artifactSectionHeader + }}</gl-dropdown-section-header> + + <gl-alert v-if="hasError" variant="danger" :dismissible="false"> + {{ $options.i18n.artifactsFetchErrorMessage }} + </gl-alert> + + <gl-loading-icon v-if="isLoading" /> + + <gl-dropdown-item + v-for="(artifact, i) in artifacts" + :key="i" + :href="artifact.path" + rel="nofollow" + download + data-testid="artifact-item" + > + <gl-sprintf :message="$options.i18n.downloadArtifact"> + <template #name>{{ artifact.name }}</template> + </gl-sprintf> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue index 81eeead2171..85ee44f427d 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue @@ -2,7 +2,7 @@ import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import eventHub from '../../event_hub'; -import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; +import PipelineMultiActions from './pipeline_multi_actions.vue'; import PipelinesManualActions from './pipelines_manual_actions.vue'; export default { @@ -16,8 +16,8 @@ export default { }, components: { GlButton, + PipelineMultiActions, PipelinesManualActions, - PipelinesArtifactsComponent, }, props: { pipeline: { @@ -36,14 +36,6 @@ export default { }; }, computed: { - displayPipelineActions() { - return ( - this.pipeline.flags.retryable || - this.pipeline.flags.cancelable || - this.pipeline.details.manual_actions.length || - this.pipeline.details.artifacts.length - ); - }, actions() { if (!this.pipeline || !this.pipeline.details) { return []; @@ -76,15 +68,10 @@ export default { </script> <template> - <div v-if="displayPipelineActions" class="gl-text-right"> + <div class="gl-text-right"> <div class="btn-group"> <pipelines-manual-actions v-if="actions.length > 0" :actions="actions" /> - <pipelines-artifacts-component - v-if="pipeline.details.artifacts.length" - :artifacts="pipeline.details.artifacts" - /> - <gl-button v-if="pipeline.flags.retryable" v-gl-tooltip.hover @@ -114,6 +101,8 @@ export default { class="js-pipelines-cancel-button" @click="handleCancelClick" /> + + <pipeline-multi-actions :pipeline-id="pipeline.id" /> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index f14a582d731..0218cb2e1b8 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -94,6 +94,11 @@ export default { type: Object, required: true, }, + codeQualityPagePath: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -331,6 +336,7 @@ export default { v-else-if="stateToRender === $options.stateMap.emptyState" :empty-state-svg-path="emptyStateSvgPath" :can-set-ci="canCreatePipeline" + :code-quality-page-path="codeQualityPagePath" /> <gl-empty-state diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue index 9c3990f82df..147fff52101 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue @@ -1,40 +1,107 @@ <script> -import { GlDropdown, GlDropdownItem, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { + GlAlert, + GlDropdown, + GlDropdownItem, + GlLoadingIcon, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { __, s__ } from '~/locale'; + +export const i18n = { + artifacts: __('Artifacts'), + downloadArtifact: __('Download %{name} artifact'), + artifactSectionHeader: __('Download artifacts'), + artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), + noArtifacts: s__('Pipelines|No artifacts available'), +}; export default { + i18n, directives: { GlTooltip: GlTooltipDirective, }, components: { + GlAlert, GlDropdown, GlDropdownItem, + GlLoadingIcon, GlSprintf, }, - translations: { - artifacts: __('Artifacts'), - downloadArtifact: __('Download %{name} artifact'), + inject: { + artifactsEndpoint: { + default: '', + }, + artifactsEndpointPlaceholder: { + default: '', + }, }, props: { - artifacts: { - type: Array, + pipelineId: { + type: Number, required: true, }, }, + data() { + return { + artifacts: [], + hasError: false, + isLoading: false, + }; + }, + computed: { + hasArtifacts() { + return Boolean(this.artifacts.length); + }, + }, + methods: { + fetchArtifacts() { + this.isLoading = true; + // Replace the placeholder with the ID of the pipeline we are viewing + const endpoint = this.artifactsEndpoint.replace( + this.artifactsEndpointPlaceholder, + this.pipelineId, + ); + return axios + .get(endpoint) + .then(({ data }) => { + this.artifacts = data.artifacts; + }) + .catch(() => { + this.hasError = true; + }) + .finally(() => { + this.isLoading = false; + }); + }, + }, }; </script> <template> <gl-dropdown v-gl-tooltip class="build-artifacts js-pipeline-dropdown-download" - :title="$options.translations.artifacts" - :text="$options.translations.artifacts" - :aria-label="$options.translations.artifacts" + :title="$options.i18n.artifacts" + :text="$options.i18n.artifacts" + :aria-label="$options.i18n.artifacts" icon="download" right lazy text-sr-only + @show.once="fetchArtifacts" > + <gl-alert v-if="hasError" variant="danger" :dismissible="false"> + {{ $options.i18n.artifactsFetchErrorMessage }} + </gl-alert> + + <gl-loading-icon v-if="isLoading" /> + + <gl-alert v-else-if="!hasArtifacts" variant="info" :dismissible="false"> + {{ $options.i18n.noArtifacts }} + </gl-alert> + <gl-dropdown-item v-for="(artifact, i) in artifacts" :key="i" @@ -42,7 +109,7 @@ export default { rel="nofollow" download > - <gl-sprintf :message="$options.translations.downloadArtifact"> + <gl-sprintf :message="$options.i18n.downloadArtifact"> <template #name>{{ artifact.name }}</template> </gl-sprintf> </gl-dropdown-item> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index 492c562ec5c..de3f783ac84 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue @@ -1,7 +1,8 @@ <script> import { GlFilteredSearch } from '@gitlab/ui'; import { map } from 'lodash'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue'; import PipelineStatusToken from './tokens/pipeline_status_token.vue'; import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue'; @@ -43,7 +44,7 @@ export default { title: s__('Pipeline|Trigger author'), unique: true, token: PipelineTriggerAuthorToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, projectId: this.projectId, }, { @@ -52,7 +53,7 @@ export default { title: s__('Pipeline|Branch name'), unique: true, token: PipelineBranchNameToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, projectId: this.projectId, disabled: this.selectedTypes.includes(this.$options.tagType), }, @@ -62,7 +63,7 @@ export default { title: s__('Pipeline|Tag name'), unique: true, token: PipelineTagNameToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, projectId: this.projectId, disabled: this.selectedTypes.includes(this.$options.branchType), }, @@ -72,7 +73,7 @@ export default { title: s__('Pipeline|Status'), unique: true, token: PipelineStatusToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, }, ]; }, diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue index cc3c8d522b3..f56457a4162 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue @@ -1,9 +1,12 @@ <script> +import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.vue'; +import { PIPELINE_STATUSES } from '~/code_quality_walkthrough/constants'; import { CHILD_VIEW } from '~/pipelines/constants'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; export default { components: { + CodeQualityWalkthrough, CiBadge, }, props: { @@ -23,15 +26,37 @@ export default { isChildView() { return this.viewType === CHILD_VIEW; }, + shouldRenderCodeQualityWalkthrough() { + return Object.values(PIPELINE_STATUSES).includes(this.pipelineStatus.group); + }, + codeQualityStep() { + const prefix = [PIPELINE_STATUSES.successWithWarnings, PIPELINE_STATUSES.failed].includes( + this.pipelineStatus.group, + ) + ? 'failed' + : this.pipelineStatus.group; + return `${prefix}_pipeline`; + }, + codeQualityBuildPath() { + return this.pipeline?.details?.code_quality_build_path; + }, }, }; </script> <template> - <ci-badge - :status="pipelineStatus" - :show-text="!isChildView" - :icon-classes="'gl-vertical-align-middle!'" - data-qa-selector="pipeline_commit_status" - /> + <div> + <ci-badge + id="js-code-quality-walkthrough" + :status="pipelineStatus" + :show-text="!isChildView" + :icon-classes="'gl-vertical-align-middle!'" + data-qa-selector="pipeline_commit_status" + /> + <code-quality-walkthrough + v-if="shouldRenderCodeQualityWalkthrough" + :step="codeQualityStep" + :link="codeQualityBuildPath" + /> + </div> </template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue b/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue new file mode 100644 index 00000000000..e9f7874d3e4 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue @@ -0,0 +1,60 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; + +export const i18n = { + noTestsButton: s__('TestReports|Learn more about pipeline test reports'), + noTestsDescription: s__('TestReports|No test cases were found in the test report.'), + noTestsTitle: s__('TestReports|There are no tests to display'), + noReportsButton: s__('TestReports|Learn how to upload pipeline test reports'), + noReportsDescription: s__( + 'TestReports|You can configure your job to use unit test reports, and GitLab displays a report here and in the related merge request.', + ), + noReportsTitle: s__('TestReports|There are no test reports for this pipeline'), +}; + +export default { + i18n, + components: { + GlEmptyState, + }, + inject: { + emptyStateImagePath: { + default: '', + }, + hasTestReport: { + default: false, + }, + }, + computed: { + emptyStateText() { + if (this.hasTestReport) { + return { + button: this.$options.i18n.noTestsButton, + description: this.$options.i18n.noTestsDescription, + title: this.$options.i18n.noTestsTitle, + }; + } + return { + button: this.$options.i18n.noReportsButton, + description: this.$options.i18n.noReportsDescription, + title: this.$options.i18n.noReportsTitle, + }; + }, + testReportDocPath() { + return helpPagePath('ci/unit_test_reports'); + }, + }, +}; +</script> + +<template> + <gl-empty-state + :title="emptyStateText.title" + :description="emptyStateText.description" + :svg-path="emptyStateImagePath" + :primary-button-link="testReportDocPath" + :primary-button-text="emptyStateText.button" + /> +</template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue index 2edc84e62cb..47e5bb0bde8 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue @@ -1,6 +1,6 @@ <script> -import { GlBadge, GlModal } from '@gitlab/ui'; -import { __, n__, sprintf } from '~/locale'; +import { GlBadge, GlFriendlyWrap, GlLink, GlModal } from '@gitlab/ui'; +import { __, n__, s__, sprintf } from '~/locale'; import CodeBlock from '~/vue_shared/components/code_block.vue'; export default { @@ -8,6 +8,8 @@ export default { components: { CodeBlock, GlBadge, + GlFriendlyWrap, + GlLink, GlModal, }, props: { @@ -50,6 +52,7 @@ export default { duration: __('Execution time'), history: __('History'), trace: __('System output'), + attachment: s__('TestReports|Attachment'), }, modalCloseButton: { text: __('Close'), @@ -85,6 +88,18 @@ export default { </div> </div> + <div v-if="testCase.attachment_url" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"> + <strong class="gl-text-right col-sm-3">{{ $options.text.attachment }}</strong> + <gl-link + class="col-sm-9" + :href="testCase.attachment_url" + target="_blank" + data-testid="test-case-attachment-url" + > + <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.attachment_url" /> + </gl-link> + </div> + <div v-if="testCase.system_output" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3" diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 58d60e2a185..58d072b0005 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import EmptyState from './empty_state.vue'; import TestSuiteTable from './test_suite_table.vue'; import TestSummary from './test_summary.vue'; import TestSummaryTable from './test_summary_table.vue'; @@ -8,6 +9,7 @@ import TestSummaryTable from './test_summary_table.vue'; export default { name: 'TestReports', components: { + EmptyState, GlLoadingIcon, TestSuiteTable, TestSummary, @@ -83,11 +85,5 @@ export default { </transition> </div> - <div v-else> - <div class="row gl-mt-3"> - <div class="col-12"> - <p data-testid="no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p> - </div> - </div> - </div> + <empty-state v-else /> </template> diff --git a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql index e4fd55a28be..e8af1db9592 100644 --- a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql +++ b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql @@ -1,4 +1,4 @@ -mutation DismissPipelineNotification($featureName: String!) { +mutation DismissPipelineGraphCallout($featureName: String!) { userCalloutCreate(input: { featureName: $featureName }) { errors } diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index a2bc049c3c7..911f40f4db3 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { deprecatedCreateFlash as Flash } from '~/flash'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import Translate from '~/vue_shared/translate'; import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue'; @@ -63,7 +64,8 @@ const createLegacyPipelinesDetailApp = (mediator) => { const createTestDetails = () => { const el = document.querySelector(SELECTORS.PIPELINE_TESTS); - const { blobPath, summaryEndpoint, suiteEndpoint } = el?.dataset || {}; + const { blobPath, emptyStateImagePath, hasTestReport, summaryEndpoint, suiteEndpoint } = + el?.dataset || {}; const testReportsStore = createTestReportsStore({ blobPath, summaryEndpoint, @@ -76,6 +78,10 @@ const createTestDetails = () => { components: { TestReports, }, + provide: { + emptyStateImagePath, + hasTestReport: parseBoolean(hasTestReport), + }, store: testReportsStore, render(createElement) { return createElement('test-reports'); diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index 9ed4365ad75..c892311782c 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -22,6 +22,8 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { const { endpoint, + artifactsEndpoint, + artifactsEndpointPlaceholder, pipelineScheduleUrl, emptyStateSvgPath, errorStateSvgPath, @@ -35,12 +37,15 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { resetCachePath, projectId, params, + codeQualityPagePath, } = el.dataset; return new Vue({ el, provide: { addCiYmlPath, + artifactsEndpoint, + artifactsEndpointPlaceholder, suggestedCiTemplates: JSON.parse(suggestedCiTemplates), }, data() { @@ -70,6 +75,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { resetCachePath, projectId, params: JSON.parse(params), + codeQualityPagePath, }, }); }, diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 0a6c326fa3d..800a363cada 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -73,3 +73,12 @@ export const reportToSentry = (component, failureType) => { Sentry.captureException(failureType); }); }; + +export const reportMessageToSentry = (component, message, context) => { + Sentry.withScope((scope) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + scope.setContext('Vue data', context); + scope.setTag('component', component); + Sentry.captureMessage(message); + }); +}; diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index e1e04d63576..7222c2bd908 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -62,6 +62,7 @@ const projectSelect = () => { with_shared: this.withShared, include_subgroups: this.includeProjectsInSubgroups, order_by: 'similarity', + simple: true, }, projectsCallback, ); diff --git a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue index d96d1035ed0..0fd31381ba6 100644 --- a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue @@ -65,6 +65,7 @@ export default { <gl-dropdown-item v-if="canRevert" data-testid="revert-link" + data-qa-selector="revert_button" @click="showModal($options.openRevertModal)" > {{ s__('ChangeTypeAction|Revert') }} diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue index d2fb524489e..f7cfc82db11 100644 --- a/app/assets/javascripts/projects/compare/components/app.vue +++ b/app/assets/javascripts/projects/compare/components/app.vue @@ -1,6 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; +import { joinPaths } from '~/lib/utils/url_utility'; import RevisionCard from './revision_card.vue'; export default { @@ -36,11 +37,46 @@ export default { type: String, required: true, }, + defaultProject: { + type: Object, + required: true, + }, + projects: { + type: Array, + required: true, + }, + }, + data() { + return { + from: { + projects: this.projects, + selectedProject: this.defaultProject, + revision: this.paramsFrom, + refsProjectPath: this.refsProjectPath, + }, + to: { + selectedProject: this.defaultProject, + revision: this.paramsTo, + refsProjectPath: this.refsProjectPath, + }, + }; }, methods: { onSubmit() { this.$refs.form.submit(); }, + onSelectProject({ direction, project }) { + const refsPath = joinPaths(gon.relative_url_root || '', `/${project.name}`, '/refs'); + // direction is either 'from' or 'to' + this[direction].refsProjectPath = refsPath; + this[direction].selectedProject = project; + }, + onSelectRevision({ direction, revision }) { + this[direction].revision = revision; // direction is either 'from' or 'to' + }, + onSwapRevision() { + [this.from, this.to] = [this.to, this.from]; // swaps 'from' and 'to' + }, }, }; </script> @@ -57,10 +93,15 @@ export default { class="gl-lg-flex-direction-row gl-lg-display-flex gl-align-items-center compare-revision-cards" > <revision-card - :refs-project-path="refsProjectPath" + data-testid="sourceRevisionCard" + :refs-project-path="to.refsProjectPath" revision-text="Source" params-name="to" - :params-branch="paramsTo" + :params-branch="to.revision" + :projects="to.projects" + :selected-project="to.selectedProject" + @selectProject="onSelectProject" + @selectRevision="onSelectRevision" /> <div class="compare-ellipsis gl-display-flex gl-justify-content-center gl-align-items-center gl-my-4 gl-md-my-0" @@ -69,16 +110,24 @@ export default { ... </div> <revision-card - :refs-project-path="refsProjectPath" + data-testid="targetRevisionCard" + :refs-project-path="from.refsProjectPath" revision-text="Target" params-name="from" - :params-branch="paramsFrom" + :params-branch="from.revision" + :projects="from.projects" + :selected-project="from.selectedProject" + @selectProject="onSelectProject" + @selectRevision="onSelectRevision" /> </div> <div class="gl-mt-4"> <gl-button category="primary" variant="success" @click="onSubmit"> {{ s__('CompareRevisions|Compare') }} </gl-button> + <gl-button data-testid="swapRevisionsButton" class="btn btn-default" @click="onSwapRevision"> + {{ s__('CompareRevisions|Swap revisions') }} + </gl-button> <gl-button v-if="projectMergeRequestPath" :href="projectMergeRequestPath" diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue index cb9d8b64b33..ba1e00a2b36 100644 --- a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue @@ -1,57 +1,51 @@ <script> import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; -const SOURCE_PARAM_NAME = 'to'; - export default { components: { GlDropdown, GlDropdownItem, GlSearchBoxByType, }, - inject: ['projectTo', 'projectsFrom'], props: { paramsName: { type: String, required: true, }, + projects: { + type: Array, + required: false, + default: null, + }, + selectedProject: { + type: Object, + required: true, + }, }, data() { return { searchTerm: '', - selectedRepo: {}, }; }, computed: { + disableRepoDropdown() { + return this.projects === null; + }, filteredRepos() { const lowerCaseSearchTerm = this.searchTerm.toLowerCase(); - return this?.projectsFrom.filter(({ name }) => - name.toLowerCase().includes(lowerCaseSearchTerm), - ); - }, - isSourceRevision() { - return this.paramsName === SOURCE_PARAM_NAME; + return this?.projects.filter(({ name }) => name.toLowerCase().includes(lowerCaseSearchTerm)); }, inputName() { return `${this.paramsName}_project_id`; }, }, - mounted() { - this.setDefaultRepo(); - }, methods: { - onClick(repo) { - this.selectedRepo = repo; - this.emitTargetProject(repo.name); - }, - setDefaultRepo() { - this.selectedRepo = this.projectTo; + onClick(project) { + this.emitTargetProject(project); }, - emitTargetProject(name) { - if (!this.isSourceRevision) { - this.$emit('changeTargetProject', name); - } + emitTargetProject(project) { + this.$emit('selectProject', { direction: this.paramsName, project }); }, }, }; @@ -59,23 +53,23 @@ export default { <template> <div> - <input type="hidden" :name="inputName" :value="selectedRepo.id" /> + <input type="hidden" :name="inputName" :value="selectedProject.id" /> <gl-dropdown - :text="selectedRepo.name" + :text="selectedProject.name" :header-text="s__(`CompareRevisions|Select target project`)" class="gl-w-full gl-font-monospace gl-sm-pr-3" toggle-class="gl-min-w-0" - :disabled="isSourceRevision" + :disabled="disableRepoDropdown" > <template #header> - <gl-search-box-by-type v-if="!isSourceRevision" v-model.trim="searchTerm" /> + <gl-search-box-by-type v-if="!disableRepoDropdown" v-model.trim="searchTerm" /> </template> - <template v-if="!isSourceRevision"> + <template v-if="!disableRepoDropdown"> <gl-dropdown-item v-for="repo in filteredRepos" :key="repo.id" is-check-item - :is-checked="selectedRepo.id === repo.id" + :is-checked="selectedProject.id === repo.id" @click="onClick(repo)" > {{ repo.name }} diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue index 15d24792310..02a329221cc 100644 --- a/app/assets/javascripts/projects/compare/components/revision_card.vue +++ b/app/assets/javascripts/projects/compare/components/revision_card.vue @@ -27,17 +27,14 @@ export default { required: false, default: null, }, - }, - data() { - return { - selectedRefsProjectPath: this.refsProjectPath, - }; - }, - methods: { - onChangeTargetProject(targetProjectName) { - if (this.paramsName === 'from') { - this.selectedRefsProjectPath = `/${targetProjectName}/refs`; - } + projects: { + type: Array, + required: false, + default: null, + }, + selectedProject: { + type: Object, + required: true, }, }, }; @@ -52,13 +49,16 @@ export default { <repo-dropdown class="gl-sm-w-half" :params-name="paramsName" - @changeTargetProject="onChangeTargetProject" + :projects="projects" + :selected-project="selectedProject" + v-on="$listeners" /> <revision-dropdown class="gl-sm-w-half gl-mt-3 gl-sm-mt-0" - :refs-project-path="selectedRefsProjectPath" + :refs-project-path="refsProjectPath" :params-name="paramsName" :params-branch="paramsBranch" + v-on="$listeners" /> </div> </gl-card> diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue index d0b69344c12..f0b8e73e528 100644 --- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue @@ -56,6 +56,9 @@ export default { searchTerm: debounce(function debounceSearch() { this.searchBranchesAndTags(); }, SEARCH_DEBOUNCE_MS), + paramsBranch(newBranch) { + this.setSelectedRevision(newBranch); + }, }, mounted() { this.fetchBranchesAndTags(); @@ -84,7 +87,7 @@ export default { this.loading = true; if (reset) { - this.selectedRevision = this.getDefaultBranch(); + this.setSelectedRevision(this.paramsBranch); } return axios @@ -108,10 +111,14 @@ export default { return this.paramsBranch || EMPTY_DROPDOWN_TEXT; }, onClick(revision) { - this.selectedRevision = revision; + this.setSelectedRevision(revision); + this.$emit('selectRevision', { direction: this.paramsName, revision }); }, onSearchEnter() { - this.selectedRevision = this.searchTerm; + this.setSelectedRevision(this.searchTerm); + }, + setSelectedRevision(revision) { + this.selectedRevision = revision || EMPTY_DROPDOWN_TEXT; }, }, }; @@ -122,7 +129,7 @@ export default { <input type="hidden" :name="paramsName" :value="selectedRevision" /> <gl-dropdown class="gl-w-full gl-font-monospace" - toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0" + toggle-class="form-control compare-dropdown-toggle gl-min-w-0" :text="selectedRevision" :header-text="s__('CompareRevisions|Select Git revision')" :loading="loading" diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue index f57a8942a77..19cf4cda2be 100644 --- a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue +++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue @@ -112,7 +112,7 @@ export default { <input type="hidden" :name="paramsName" :value="selectedRevision" /> <gl-dropdown class="gl-flex-grow-1 gl-flex-basis-0 gl-min-w-0 gl-font-monospace" - toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!" + toggle-class="form-control compare-dropdown-toggle gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!" :text="selectedRevision" header-text="Select Git revision" :loading="loading" diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js index 4ba4e308cd4..322dff773b8 100644 --- a/app/assets/javascripts/projects/compare/index.js +++ b/app/assets/javascripts/projects/compare/index.js @@ -22,10 +22,6 @@ export default function init() { components: { CompareApp, }, - provide: { - projectTo: JSON.parse(projectTo), - projectsFrom: JSON.parse(projectsFrom), - }, render(createElement) { return createElement(CompareApp, { props: { @@ -35,6 +31,8 @@ export default function init() { projectCompareIndexPath, projectMergeRequestPath, createMrPath, + defaultProject: JSON.parse(projectTo), + projects: JSON.parse(projectsFrom), }, }); }, diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue deleted file mode 100644 index 1060b37067e..00000000000 --- a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue +++ /dev/null @@ -1,201 +0,0 @@ -<script> -/* eslint-disable vue/no-v-html */ -import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import { experiment } from '~/experimentation/utils'; -import { __, s__ } from '~/locale'; -import { NEW_REPO_EXPERIMENT } from '../constants'; -import blankProjectIllustration from '../illustrations/blank-project.svg'; -import ciCdProjectIllustration from '../illustrations/ci-cd-project.svg'; -import createFromTemplateIllustration from '../illustrations/create-from-template.svg'; -import importProjectIllustration from '../illustrations/import-project.svg'; -import LegacyContainer from './legacy_container.vue'; -import WelcomePage from './welcome.vue'; - -const BLANK_PANEL = 'blank_project'; -const CI_CD_PANEL = 'cicd_for_external_repo'; -const LAST_ACTIVE_TAB_KEY = 'new_project_last_active_tab'; - -const PANELS = [ - { - key: 'blank', - name: BLANK_PANEL, - selector: '#blank-project-pane', - title: s__('ProjectsNew|Create blank project'), - description: s__( - 'ProjectsNew|Create a blank project to house your files, plan your work, and collaborate on code, among other things.', - ), - illustration: blankProjectIllustration, - }, - { - key: 'template', - name: 'create_from_template', - selector: '#create-from-template-pane', - title: s__('ProjectsNew|Create from template'), - description: s__( - 'Create a project pre-populated with the necessary files to get you started quickly.', - ), - illustration: createFromTemplateIllustration, - }, - { - key: 'import', - name: 'import_project', - selector: '#import-project-pane', - title: s__('ProjectsNew|Import project'), - description: s__( - 'Migrate your data from an external source like GitHub, Bitbucket, or another instance of GitLab.', - ), - illustration: importProjectIllustration, - }, - { - key: 'ci', - name: CI_CD_PANEL, - selector: '#ci-cd-project-pane', - title: s__('ProjectsNew|Run CI/CD for external repository'), - description: s__('ProjectsNew|Connect your external repository to GitLab CI/CD.'), - illustration: ciCdProjectIllustration, - }, -]; - -export default { - components: { - GlBreadcrumb, - GlIcon, - WelcomePage, - LegacyContainer, - }, - directives: { - SafeHtml, - }, - props: { - hasErrors: { - type: Boolean, - required: false, - default: false, - }, - isCiCdAvailable: { - type: Boolean, - required: false, - default: false, - }, - newProjectGuidelines: { - type: String, - required: false, - default: '', - }, - }, - - data() { - return { - activeTab: null, - }; - }, - - computed: { - decoratedPanels() { - const PANEL_TITLES = experiment(NEW_REPO_EXPERIMENT, { - use: () => ({ - blank: s__('ProjectsNew|Create blank project'), - import: s__('ProjectsNew|Import project'), - }), - try: () => ({ - blank: s__('ProjectsNew|Create blank project/repository'), - import: s__('ProjectsNew|Import project/repository'), - }), - }); - - return PANELS.map(({ key, title, ...el }) => ({ - ...el, - title: PANEL_TITLES[key] !== undefined ? PANEL_TITLES[key] : title, - })); - }, - - availablePanels() { - if (this.isCiCdAvailable) { - return this.decoratedPanels; - } - - return this.decoratedPanels.filter((p) => p.name !== CI_CD_PANEL); - }, - - activePanel() { - return this.decoratedPanels.find((p) => p.name === this.activeTab); - }, - - breadcrumbs() { - if (!this.activeTab || !this.activePanel) { - return null; - } - - return [ - { text: __('New project'), href: '#' }, - { text: this.activePanel.title, href: `#${this.activeTab}` }, - ]; - }, - }, - - created() { - this.handleLocationHashChange(); - - if (this.hasErrors) { - this.activeTab = localStorage.getItem(LAST_ACTIVE_TAB_KEY) || BLANK_PANEL; - } - - window.addEventListener('hashchange', () => { - this.handleLocationHashChange(); - this.resetProjectErrors(); - }); - this.$root.$on('clicked::link', (e) => { - window.location = e.target.href; - }); - }, - - methods: { - resetProjectErrors() { - const errorsContainer = document.querySelector('.project-edit-errors'); - if (errorsContainer) { - errorsContainer.innerHTML = ''; - } - }, - - handleLocationHashChange() { - this.activeTab = window.location.hash.substring(1) || null; - if (this.activeTab) { - localStorage.setItem(LAST_ACTIVE_TAB_KEY, this.activeTab); - } - }, - }, - - PANELS, -}; -</script> - -<template> - <welcome-page v-if="activeTab === null" :panels="availablePanels" /> - <div v-else class="row"> - <div class="col-lg-3"> - <div class="gl-text-white" v-html="activePanel.illustration"></div> - <h4>{{ activePanel.title }}</h4> - <p>{{ activePanel.description }}</p> - <div - v-if="newProjectGuidelines" - id="new-project-guideline" - v-safe-html="newProjectGuidelines" - ></div> - </div> - <div class="col-lg-9"> - <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs"> - <template #separator> - <gl-icon name="chevron-right" :size="8" /> - </template> - </gl-breadcrumb> - <template v-for="panel in $options.PANELS"> - <legacy-container - v-if="activeTab === panel.name" - :key="panel.name" - class="gl-mt-3" - :selector="panel.selector" - /> - </template> - </div> - </div> -</template> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue deleted file mode 100644 index d342ce4c9c2..00000000000 --- a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue +++ /dev/null @@ -1,66 +0,0 @@ -<script> -/* eslint-disable vue/no-v-html */ -import Tracking from '~/tracking'; -import { NEW_REPO_EXPERIMENT } from '../constants'; -import NewProjectPushTipPopover from './new_project_push_tip_popover.vue'; - -const trackingMixin = Tracking.mixin({ ...gon.tracking_data, experiment: NEW_REPO_EXPERIMENT }); - -export default { - components: { - NewProjectPushTipPopover, - }, - mixins: [trackingMixin], - props: { - panels: { - type: Array, - required: true, - }, - }, -}; -</script> -<template> - <div class="container"> - <div class="blank-state-welcome"> - <h2 class="blank-state-welcome-title gl-mt-5! gl-mb-3!"> - {{ s__('ProjectsNew|Create new project') }} - </h2> - <p div class="blank-state-text"> </p> - </div> - <div class="row blank-state-row"> - <a - v-for="panel in panels" - :key="panel.name" - :href="`#${panel.name}`" - :data-qa-selector="`${panel.name}_link`" - class="blank-state blank-state-link experiment-new-project-page-blank-state" - @click="track('click_tab', { label: panel.name })" - > - <div class="blank-state-icon gl-text-white" v-html="panel.illustration"></div> - <div class="blank-state-body gl-pl-4!"> - <h3 class="blank-state-title experiment-new-project-page-blank-state-title"> - {{ panel.title }} - </h3> - <p class="blank-state-text"> - {{ panel.description }} - </p> - </div> - </a> - </div> - <div class="blank-state-welcome"> - <p> - {{ __('You can also create a project from the command line.') }} - <a - ref="clipTip" - href="#" - click.prevent - class="push-new-project-tip" - rel="noopener noreferrer" - > - {{ __('Show command') }} - </a> - <new-project-push-tip-popover :target="() => $refs.clipTip" /> - </p> - </div> - </div> -</template> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/constants.js b/app/assets/javascripts/projects/experiment_new_project_creation/constants.js deleted file mode 100644 index 402ca887cf1..00000000000 --- a/app/assets/javascripts/projects/experiment_new_project_creation/constants.js +++ /dev/null @@ -1 +0,0 @@ -export const NEW_REPO_EXPERIMENT = 'new_repo'; diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg deleted file mode 100644 index f73ae70dba8..00000000000 --- a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg +++ /dev/null @@ -1,9 +0,0 @@ -<svg width="82" height="80" viewBox="0 0 82 80" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M66.1912 8.19118H77.6176C78.2755 8.19118 78.8088 8.72448 78.8088 9.38235V69.6176C78.8088 70.2755 78.2755 70.8088 77.6176 70.8088H66.1912V8.19118Z" fill="#F0F0F0" stroke="#DBDBDB" stroke-width="2.38235"/> -<path d="M22.0517 19.2723L22.0094 10.1001C22.004 8.92546 22.8555 7.92221 24.0153 7.73664L63.3613 1.44139C64.8087 1.2098 66.12 2.32794 66.12 3.79382V75.8717C66.12 77.3323 64.8177 78.449 63.3742 78.2262L24.3037 72.1952C23.1461 72.0165 22.2902 71.023 22.2848 69.8517L22.2428 60.7554" stroke="#DBDBDB" stroke-width="2.38235"/> -<circle cx="23" cy="40" r="21" stroke="#6E49CB" stroke-width="2.38235"/> -<circle cx="23" cy="40" r="17" fill="#6E49CB"/> -<circle cx="23" cy="40" r="17" fill="white" fill-opacity="0.9"/> -<path d="M22.3125 48V33.3659" stroke="#6E49CB" stroke-width="2.38235" stroke-linecap="round"/> -<path d="M15 40.3049H30" stroke="#6E49CB" stroke-width="2.38235" stroke-linecap="round"/> -</svg> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg deleted file mode 100644 index 8d6cf58f196..00000000000 --- a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg +++ /dev/null @@ -1,23 +0,0 @@ -<svg width="169" height="78" viewBox="0 0 169 78" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M115.571 41.5714L147.714 41.5714C158.365 41.5714 167 32.9369 167 22.2857C167 11.6345 158.365 3 147.714 3C137.063 3 128.429 11.6345 128.429 22.2857C128.429 27.3128 130.352 31.8907 133.503 35.3235" stroke="#DBDBDB" stroke-width="2.63203" stroke-linecap="round"/> -<path d="M115.107 41.5714H125.786C133.084 41.5714 139 47.4877 139 54.7857C139 62.0838 133.084 68 125.786 68C118.488 68 112.571 62.0838 112.571 54.7857C112.571 53.039 112.91 51.3715 113.526 49.8453" stroke="#DBDBDB" stroke-width="2.63203" stroke-linecap="round"/> -<path d="M87.5486 37H76.3943C75.6243 37 75 36.3746 75 35.6032C75 34.8318 75.6243 34.2064 76.3943 34.2064H87.5486C88.3187 34.2064 88.9429 34.8318 88.9429 35.6032C88.9429 36.3746 88.3187 37 87.5486 37Z" fill="#FC6D26"/> -<path d="M118.703 37H96.3943C95.6243 37 95 36.3746 95 35.6032C95 34.8318 95.6243 34.2064 96.3943 34.2064H118.703C119.473 34.2064 120.097 34.8318 120.097 35.6032C120.097 36.3746 119.473 37 118.703 37Z" fill="#FC6D26"/> -<path d="M118.703 37H96.3943C95.6243 37 95 36.3746 95 35.6032C95 34.8318 95.6243 34.2064 96.3943 34.2064H118.703C119.473 34.2064 120.097 34.8318 120.097 35.6032C120.097 36.3746 119.473 37 118.703 37Z" fill="white" fill-opacity="0.6"/> -<path d="M93.8573 32H71.3944C70.6243 32 70.0001 31.3746 70.0001 30.6032C70.0001 29.8318 70.6243 29.2064 71.3944 29.2064L93.8573 29.2064C94.6273 29.2064 95.2516 29.8318 95.2516 30.6032C95.2516 31.3746 94.6273 32 93.8573 32Z" fill="#6E49CB"/> -<path d="M93.8573 32H71.3944C70.6243 32 70.0001 31.3746 70.0001 30.6032C70.0001 29.8318 70.6243 29.2064 71.3944 29.2064L93.8573 29.2064C94.6273 29.2064 95.2516 29.8318 95.2516 30.6032C95.2516 31.3746 94.6273 32 93.8573 32Z" fill="white" fill-opacity="0.8"/> -<path d="M86.8573 49H71.3944C70.6243 49 70.0001 48.3746 70.0001 47.6032C70.0001 46.8317 70.6243 46.2064 71.3944 46.2064H86.8573C87.6273 46.2064 88.2516 46.8317 88.2516 47.6032C88.2516 48.3746 87.6273 49 86.8573 49Z" fill="#6E49CB"/> -<path d="M86.8573 49H71.3944C70.6243 49 70.0001 48.3746 70.0001 47.6032C70.0001 46.8317 70.6243 46.2064 71.3944 46.2064H86.8573C87.6273 46.2064 88.2516 46.8317 88.2516 47.6032C88.2516 48.3746 87.6273 49 86.8573 49Z" fill="white" fill-opacity="0.8"/> -<path d="M109.166 43L73.3944 43C72.6243 43 72.0001 42.3746 72.0001 41.6032C72.0001 40.8317 72.6243 40.2064 73.3944 40.2064L109.166 40.2064C109.936 40.2064 110.56 40.8317 110.56 41.6032C110.56 42.3746 109.936 43 109.166 43Z" fill="#6E49CB"/> -<path d="M109.166 43L73.3944 43C72.6243 43 72.0001 42.3746 72.0001 41.6032C72.0001 40.8317 72.6243 40.2064 73.3944 40.2064L109.166 40.2064C109.936 40.2064 110.56 40.8317 110.56 41.6032C110.56 42.3746 109.936 43 109.166 43Z" fill="white" fill-opacity="0.4"/> -<path d="M146.262 24.2349L143.048 21.0153C142.767 20.7338 142.282 20.7323 141.983 21.0313L140.394 22.6236C140.1 22.9181 140.088 23.4002 140.378 23.6903L145.344 28.6651C145.841 29.1637 146.666 29.1795 147.166 28.6793L147.866 27.9779L155.864 19.9653C156.171 19.658 156.167 19.1776 155.868 18.8786L154.279 17.2863C153.985 16.9918 153.495 16.9891 153.194 17.2903L146.262 24.2349Z" fill="#FC6D26"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M125.682 56.7113L123.087 59.3221C122.858 59.5529 122.547 59.6825 122.223 59.6824C121.898 59.6824 121.587 59.5526 121.358 59.3218C121.129 59.091 121 58.7779 121 58.4515C121 58.1251 121.129 57.8121 121.358 57.5813L123.087 55.8412L121.358 54.1011C121.129 53.8703 121 53.5573 121 53.2309C121 52.9045 121.129 52.5915 121.358 52.3606C121.587 52.1298 121.898 52.0001 122.223 52C122.547 51.9999 122.858 52.1296 123.087 52.3603L125.682 54.9711C125.911 55.2019 126.04 55.5149 126.04 55.8412C126.04 56.1675 125.911 56.4805 125.682 56.7113ZM131.796 56.7113L129.202 59.3221C129.088 59.4364 128.954 59.527 128.805 59.5888C128.657 59.6506 128.498 59.6824 128.337 59.6824C128.177 59.6824 128.018 59.6505 127.869 59.5886C127.721 59.5268 127.586 59.4361 127.472 59.3218C127.359 59.2075 127.269 59.0718 127.207 58.9225C127.146 58.7732 127.114 58.6131 127.114 58.4515C127.114 58.2899 127.146 58.1299 127.208 57.9806C127.269 57.8313 127.359 57.6956 127.473 57.5813L129.202 55.8412L127.473 54.1011C127.359 53.9868 127.269 53.8512 127.208 53.7018C127.146 53.5525 127.114 53.3925 127.114 53.2309C127.114 53.0693 127.146 52.9092 127.207 52.7599C127.269 52.6106 127.359 52.4749 127.472 52.3606C127.586 52.2463 127.721 52.1556 127.869 52.0938C128.018 52.0319 128.177 52 128.337 52C128.498 52 128.657 52.0318 128.805 52.0936C128.954 52.1554 129.088 52.246 129.202 52.3603L131.796 54.9711C132.026 55.2019 132.154 55.5149 132.154 55.8412C132.154 56.1675 132.026 56.4805 131.796 56.7113Z" fill="#6E49CB"/> -<path d="M2 26C2 28.415 14.4361 30.3727 29.7769 30.3727C33.7709 30.3727 37.568 30.24 41 30.0011" stroke="#DBDBDB" stroke-width="1.28173"/> -<path d="M2 50C2 52.415 14.4361 54.3727 29.7769 54.3727C35.6133 54.3727 41.0293 54.0893 45.5 53.6052" stroke="#DBDBDB" stroke-width="1.28173"/> -<path d="M57.5537 5V22M2 5V68.6673C2 73.1731 20.9696 75.5204 29.7769 75.5204C38.5842 75.5204 57.5537 73.1731 57.5537 68.6673V57" stroke="#DBDBDB" stroke-width="2.56346" stroke-linejoin="round"/> -<ellipse cx="29.7769" cy="5.64391" rx="27.7769" ry="3.64391" stroke="#DBDBDB" stroke-width="2.56346"/> -<ellipse cx="55.4286" cy="39.46" rx="17.4286" ry="17.46" stroke="#6E49CB" stroke-width="2.56346"/> -<ellipse cx="55.2458" cy="39.2696" rx="13.2458" ry="13.2696" fill="#6E49CB"/> -<ellipse cx="55.2458" cy="39.2696" rx="13.2458" ry="13.2696" fill="white" fill-opacity="0.9"/> -<path d="M61.763 38.5893C62.5797 39.0892 62.5797 40.2756 61.763 40.7756L52.951 46.1704C52.0969 46.6933 51 46.0787 51 45.0773L51 34.2875C51 33.2861 52.0969 32.6715 52.951 33.1944L61.763 38.5893Z" fill="#6E49CB"/> -</svg> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg deleted file mode 100644 index 2ff4e4969b1..00000000000 --- a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg +++ /dev/null @@ -1,13 +0,0 @@ -<svg width="82" height="80" viewBox="0 0 82 80" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M68.1765 8.17647H79.6471C80.2968 8.17647 80.8235 8.70319 80.8235 9.35294V69.6471C80.8235 70.2968 80.2968 70.8235 79.6471 70.8235H68.1765V8.17647Z" fill="#F0F0F0" stroke="#DBDBDB" stroke-width="2.35294"/> -<path d="M24.0504 19L24.0093 10.0746C24.0039 8.9145 24.8449 7.92363 25.9905 7.74035L65.393 1.43595C66.8226 1.20721 68.1176 2.31155 68.1176 3.75934V75.903C68.1176 77.3456 66.8314 78.4485 65.4057 78.2284L26.2788 72.1887C25.1356 72.0122 24.2902 71.0309 24.2849 69.8742L24.244 61" stroke="#DBDBDB" stroke-width="2.35294"/> -<path d="M60.0194 11.1796L30.0195 15.2198C29.4357 15.2984 29 15.7966 29 16.3857V19.1235C29 19.8153 29.594 20.3578 30.283 20.2951L60.283 17.5679C60.889 17.5128 61.3529 17.0047 61.3529 16.3962V12.3455C61.3529 11.6334 60.7252 11.0845 60.0194 11.1796Z" fill="#DBDBDB" stroke="#DBDBDB" stroke-width="0.588235" stroke-linecap="round" stroke-linejoin="bevel"/> -<path d="M51.1704 29.1021L41.8902 29.8481C41.0202 29.918 40.5266 30.8776 40.9756 31.626L42.6523 34.4205C42.8676 34.7793 43.2573 34.9968 43.6758 34.9916L51.2794 34.8968C51.9233 34.8888 52.4412 34.3645 52.4412 33.7205V30.2748C52.4412 29.5879 51.8551 29.0471 51.1704 29.1021Z" fill="#DBDBDB" stroke="#DBDBDB" stroke-width="0.588235" stroke-linecap="round" stroke-linejoin="bevel"/> -<path d="M61.2104 70.6341V40.1765C61.2104 39.5267 60.6837 39 60.0339 39H44.9909C44.4469 39 43.9738 39.373 43.8469 39.9019L41.118 51.2721C41.0819 51.4226 41.0148 51.5672 40.923 51.6918C37.1778 56.7763 34.7228 57.4741 29.7135 59.6826C29.2815 59.873 29.0064 60.3064 29.0162 60.7783L29.1309 66.295C29.1428 66.8693 29.5679 67.3511 30.1362 67.4345L59.8631 71.7981C60.5732 71.9024 61.2104 71.3519 61.2104 70.6341Z" fill="#DBDBDB" stroke="#DBDBDB" stroke-width="0.588235" stroke-linecap="round" stroke-linejoin="bevel"/> -<path d="M43.5694 24L36 24.5" stroke="#DBDBDB" stroke-width="1.17647" stroke-linecap="round"/> -<circle cx="23" cy="40" r="21" stroke="#6E49CB" stroke-width="2.35294"/> -<circle cx="23" cy="40" r="17" fill="#6E49CB"/> -<circle cx="23" cy="40" r="17" fill="white" fill-opacity="0.9"/> -<path d="M22.3125 48V33" stroke="#6E49CB" stroke-width="2.35294" stroke-linecap="round"/> -<path d="M15 41.3148H30" stroke="#6E49CB" stroke-width="2.35294" stroke-linecap="round"/> -</svg> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg deleted file mode 100644 index 46b4b097bb6..00000000000 --- a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg +++ /dev/null @@ -1,38 +0,0 @@ -<svg width="169" height="84" viewBox="0 0 169 84" fill="none" xmlns="http://www.w3.org/2000/svg"> -<g clip-path="url(#clip0)"> -<path d="M153.5 74.5714H165.684C166.411 74.5714 167 73.9822 167 73.2554V8.74461C167 8.01779 166.411 7.42859 165.684 7.42859H153.5" stroke="#DBDBDB" stroke-width="2.63203"/> -<path d="M107.94 57L108.014 72.9062C108.017 73.5536 108.49 74.1026 109.13 74.2008L151.913 80.7674C152.71 80.8897 153.429 80.273 153.429 79.4666V2.54193C153.429 1.73264 152.705 1.11511 151.906 1.24226L108.829 8.09543C108.187 8.19744 107.716 8.7519 107.719 9.4012L107.771 20.5" stroke="#DBDBDB" stroke-width="2.63203"/> -<path d="M133.539 52.5313L122.91 51.9925M137.311 52.7225L148.969 53.3135" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/> -<path d="M132.224 43.9783L124 43.6955M135.998 44.1081L147.665 44.5092" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/> -<path d="M148.238 12.3644L131.189 14.604M117.282 16.4529L126.416 15.2311" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/> -<path d="M149.032 36.8519L131.839 37.0342M125 37.0852L127.024 37.0852" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/> -<path d="M112.038 66.3444L120.582 67.4102M148.266 70.8634L134.595 69.1581M125.025 67.9644L129.468 68.5186" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/> -<path d="M114.352 23.3947L116.215 23.2387M129.258 22.147L119.433 22.9693M137.388 21.4665L145.18 20.8143" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/> -<path d="M135.832 29.2067L125.981 29.5888M138.724 28.9864L146.537 28.6833" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/> -<path d="M115.114 59.5557L128.942 60.8796M133.782 61.3429L145.19 62.4351" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/> -<path d="M53.4286 42.4286H21.2857C10.6345 42.4286 2.00002 33.7941 2.00002 23.1429C2.00002 12.4917 10.6345 3.85718 21.2857 3.85718C31.9369 3.85718 40.5714 12.4917 40.5714 23.1429C40.5714 28.17 38.648 32.7479 35.4969 36.1807" stroke="#DBDBDB" stroke-width="2.63203" stroke-linecap="round"/> -<path d="M53.0361 42.4286H42.3571C35.0591 42.4286 29.1428 48.3448 29.1428 55.6429C29.1428 62.9409 35.0591 68.8572 42.3571 68.8572C49.6552 68.8572 55.5714 62.9409 55.5714 55.6429C55.5714 53.8962 55.2325 52.2287 54.6169 50.7025" stroke="#DBDBDB" stroke-width="2.63203" stroke-linecap="round"/> -<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4286 51.7144C38.4286 50.9254 39.0682 50.2858 39.8572 50.2858H44.1429C44.829 50.2858 45.4022 50.7695 45.5399 51.4146L47.7105 52.6677C48.3938 53.0622 48.6279 53.9359 48.2334 54.6192C47.3183 56.2042 45.5714 59.2248 45.4609 59.4191C45.1836 59.9063 44.7237 60.2858 44.1429 60.2858H39.8572C39.0682 60.2858 38.4286 59.6462 38.4286 58.8572V51.7144ZM39.8572 51.7144H44.1429V58.8572H39.8572L39.8572 51.7144ZM45.5714 56.3727L46.9962 53.9049L45.5714 53.0823V56.3727Z" fill="#FC6D26"/> -<path d="M25.5984 15.2331C25.8026 14.471 25.3503 13.6877 24.5882 13.4835C23.8261 13.2793 23.0428 13.7315 22.8386 14.4936L18.4017 31.0524C18.1975 31.8145 18.6497 32.5978 19.4118 32.802C20.1739 33.0062 20.9573 32.5539 21.1615 31.7918L25.5984 15.2331Z" fill="#6E49CB"/> -<path d="M17.2958 17.8469C17.8537 18.4048 17.8537 19.3093 17.2958 19.8672L14.0203 23.1428L17.2958 26.4183C17.8537 26.9762 17.8537 27.8807 17.2958 28.4386C16.738 28.9965 15.8334 28.9965 15.2755 28.4386L10.9898 24.1529C10.4319 23.595 10.4319 22.6905 10.9898 22.1326L15.2755 17.8469C15.8334 17.289 16.738 17.289 17.2958 17.8469Z" fill="#6E49CB"/> -<path d="M26.7041 17.8469C26.1462 18.4048 26.1462 19.3093 26.7041 19.8672L29.9797 23.1428L26.7041 26.4183C26.1462 26.9762 26.1462 27.8807 26.7041 28.4386C27.262 28.9965 28.1665 28.9965 28.7244 28.4386L33.0101 24.1529C33.568 23.595 33.568 22.6905 33.0101 22.1326L28.7244 17.8469C28.1665 17.289 27.262 17.289 26.7041 17.8469Z" fill="#6E49CB"/> -<path d="M50.5714 35.2857L62 35.2857C62.7889 35.2857 63.4285 35.9253 63.4285 36.7143C63.4285 37.5032 62.7889 38.1428 62 38.1428L50.5714 38.1428C49.7824 38.1428 49.1428 37.5032 49.1428 36.7143C49.1428 35.9253 49.7824 35.2857 50.5714 35.2857Z" fill="#FC6D26"/> -<path d="M50.5714 35.2857L62 35.2857C62.7889 35.2857 63.4285 35.9253 63.4285 36.7143C63.4285 37.5032 62.7889 38.1428 62 38.1428L50.5714 38.1428C49.7824 38.1428 49.1428 37.5032 49.1428 36.7143C49.1428 35.9253 49.7824 35.2857 50.5714 35.2857Z" fill="white" fill-opacity="0.6"/> -<path d="M70.5713 35.2857L83.4285 35.2857C84.2175 35.2857 84.8571 35.9253 84.8571 36.7143C84.8571 37.5032 84.2175 38.1428 83.4285 38.1428L70.5713 38.1428C69.7824 38.1428 69.1428 37.5032 69.1428 36.7143C69.1428 35.9253 69.7824 35.2857 70.5713 35.2857Z" fill="#FC6D26"/> -<path d="M76.2856 46.7144L92.1428 46.7144C92.9318 46.7144 93.5714 47.3539 93.5714 48.1429C93.5714 48.9319 92.9318 49.5715 92.1428 49.5715L76.2856 49.5715C75.4967 49.5715 74.8571 48.9319 74.8571 48.1429C74.8571 47.354 75.4967 46.7144 76.2856 46.7144Z" fill="#6E49CB"/> -<path d="M76.2856 46.7144L92.1428 46.7144C92.9318 46.7144 93.5714 47.3539 93.5714 48.1429C93.5714 48.9319 92.9318 49.5715 92.1428 49.5715L76.2856 49.5715C75.4967 49.5715 74.8571 48.9319 74.8571 48.1429C74.8571 47.354 75.4967 46.7144 76.2856 46.7144Z" fill="white" fill-opacity="0.8"/> -<path d="M62.7142 40.9999L90 40.9999C90.7889 40.9999 91.4285 41.6395 91.4285 42.4285C91.4285 43.2175 90.7889 43.8571 90 43.8571L62.7142 43.8571C61.9253 43.8571 61.2857 43.2175 61.2857 42.4285C61.2857 41.6395 61.9253 40.9999 62.7142 40.9999Z" fill="#6E49CB"/> -<path d="M62.7142 40.9999L90 40.9999C90.7889 40.9999 91.4285 41.6395 91.4285 42.4285C91.4285 43.2175 90.7889 43.8571 90 43.8571L62.7142 43.8571C61.9253 43.8571 61.2857 43.2175 61.2857 42.4285C61.2857 41.6395 61.9253 40.9999 62.7142 40.9999Z" fill="white" fill-opacity="0.6"/> -<path d="M69.8571 29.5714L91.5714 29.5714C92.3603 29.5714 92.9999 30.211 92.9999 31C92.9999 31.789 92.3603 32.4286 91.5714 32.4286L69.8571 32.4286C69.0681 32.4286 68.4285 31.789 68.4285 31C68.4285 30.211 69.0681 29.5714 69.8571 29.5714Z" fill="#6E49CB"/> -<path d="M69.8571 29.5714L91.5714 29.5714C92.3603 29.5714 92.9999 30.211 92.9999 31C92.9999 31.789 92.3603 32.4286 91.5714 32.4286L69.8571 32.4286C69.0681 32.4286 68.4285 31.789 68.4285 31C68.4285 30.211 69.0681 29.5714 69.8571 29.5714Z" fill="white" fill-opacity="0.8"/> -<circle cx="107.714" cy="38.8571" r="17.8571" stroke="#6E49CB" stroke-width="2.63203"/> -<circle cx="107.714" cy="38.8573" r="13.5714" fill="#6E49CB"/> -<circle cx="107.714" cy="38.8573" r="13.5714" fill="white" fill-opacity="0.9"/> -<path d="M111.431 35.0867L115.367 39.0232L111.431 42.9597C111.016 43.3744 110.344 43.3744 109.929 42.9597C109.515 42.545 109.515 41.8727 109.929 41.458L111.302 40.0851H101.123C100.537 40.0851 100.061 39.6097 100.061 39.0232C100.061 38.4367 100.537 37.9613 101.123 37.9613H111.302L109.929 36.5884C109.515 36.1737 109.515 35.5014 109.929 35.0867C110.344 34.672 111.016 34.672 111.431 35.0867Z" fill="#6E49CB"/> -</g> -<defs> -<clipPath id="clip0"> -<rect width="169" height="84" fill="white"/> -</clipPath> -</defs> -</svg> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/index.js b/app/assets/javascripts/projects/experiment_new_project_creation/index.js deleted file mode 100644 index ea686d4e1e8..00000000000 --- a/app/assets/javascripts/projects/experiment_new_project_creation/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import Vue from 'vue'; -import NewProjectCreationApp from './components/app.vue'; - -export default function initNewProjectCreation(el, props) { - const { pushToCreateProjectCommand, workingWithProjectsHelpPath } = el.dataset; - - return new Vue({ - el, - components: { - NewProjectCreationApp, - }, - provide: { - workingWithProjectsHelpPath, - pushToCreateProjectCommand, - }, - render(h) { - return h(NewProjectCreationApp, { props }); - }, - }); -} diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue index 8d005373508..25bacc1cc4a 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -9,9 +9,8 @@ export default { GlTab, PipelineCharts, DeploymentFrequencyCharts: () => - import('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue'), - LeadTimeCharts: () => - import('ee_component/projects/pipelines/charts/components/lead_time_charts.vue'), + import('ee_component/dora/components/deployment_frequency_charts.vue'), + LeadTimeCharts: () => import('ee_component/dora/components/lead_time_charts.vue'), }, inject: { shouldRenderDoraCharts: { @@ -29,7 +28,7 @@ export default { const chartsToShow = ['pipelines']; if (this.shouldRenderDoraCharts) { - chartsToShow.push('deployments', 'lead-time'); + chartsToShow.push('deployment-frequency', 'lead-time'); } return chartsToShow; @@ -62,10 +61,10 @@ export default { <pipeline-charts /> </gl-tab> <template v-if="shouldRenderDoraCharts"> - <gl-tab :title="__('Deployments')"> + <gl-tab :title="__('Deployment frequency')"> <deployment-frequency-charts /> </gl-tab> - <gl-tab :title="__('Lead Time')"> + <gl-tab :title="__('Lead time')"> <lead-time-charts /> </gl-tab> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue index 6a963616224..1c4413bef71 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue @@ -4,6 +4,7 @@ import { GlColumnChart } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; import { getDateInPast } from '~/lib/utils/datetime_utility'; import { __, s__, sprintf } from '~/locale'; +import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue'; import { DEFAULT, CHART_CONTAINER_HEIGHT, @@ -21,7 +22,6 @@ import { } from '../constants'; import getPipelineCountByStatus from '../graphql/queries/get_pipeline_count_by_status.query.graphql'; import getProjectPipelineStatistics from '../graphql/queries/get_project_pipeline_statistics.query.graphql'; -import CiCdAnalyticsCharts from './ci_cd_analytics_charts.vue'; import StatisticsList from './statistics_list.vue'; const defaultAnalyticsValues = { @@ -301,7 +301,7 @@ export default { <statistics-list v-else :counts="formattedCounts" /> </div> <div v-if="!loading" class="col-md-6"> - <strong>{{ __('Duration for the last 30 commits') }}</strong> + <strong>{{ __('Pipeline durations for the last 30 commits') }}</strong> <gl-column-chart :height="$options.chartContainerHeight" :option="$options.timesChartOptions" diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue index f46068acd68..80ed9a32039 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { sprintf, n__ } from '~/locale'; +import { sprintf, n__, s__ } from '~/locale'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -23,6 +23,8 @@ import { ROOT_IMAGE_TOOLTIP, } from '../../constants/index'; +import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_container_repository_tags_count.query.graphql'; + export default { name: 'DetailsHeader', components: { GlButton, GlIcon, TitleArea, MetadataItem }, @@ -35,60 +37,77 @@ export default { type: Object, required: true, }, - metadataLoading: { - type: Boolean, - required: false, - default: false, - }, disabled: { type: Boolean, default: false, required: false, }, }, + data() { + return { + containerRepository: {}, + fetchTagsCount: false, + }; + }, + apollo: { + containerRepository: { + query: getContainerRepositoryTagsCountQuery, + variables() { + return { + id: this.image.id, + }; + }, + }, + }, computed: { + imageDetails() { + return { ...this.image, ...this.containerRepository }; + }, visibilityIcon() { - return this.image?.project?.visibility === 'public' ? 'eye' : 'eye-slash'; + return this.imageDetails?.project?.visibility === 'public' ? 'eye' : 'eye-slash'; }, timeAgo() { - return this.timeFormatted(this.image.updatedAt); + return this.timeFormatted(this.imageDetails.updatedAt); }, updatedText() { return sprintf(UPDATED_AT, { time: this.timeAgo }); }, tagCountText() { - return n__('%d tag', '%d tags', this.image.tagsCount); + if (this.$apollo.queries.containerRepository.loading) { + return s__('ContainerRegistry|-- tags'); + } + return n__('%d tag', '%d tags', this.imageDetails.tagsCount); }, cleanupTextAndTooltip() { - if (!this.image.project.containerExpirationPolicy?.enabled) { + if (!this.imageDetails.project.containerExpirationPolicy?.enabled) { return { text: CLEANUP_DISABLED_TEXT, tooltip: CLEANUP_DISABLED_TOOLTIP }; } return { [UNSCHEDULED_STATUS]: { text: sprintf(CLEANUP_UNSCHEDULED_TEXT, { - time: this.timeFormatted(this.image.project.containerExpirationPolicy.nextRunAt), + time: this.timeFormatted(this.imageDetails.project.containerExpirationPolicy.nextRunAt), }), }, [SCHEDULED_STATUS]: { text: CLEANUP_SCHEDULED_TEXT, tooltip: CLEANUP_SCHEDULED_TOOLTIP }, [ONGOING_STATUS]: { text: CLEANUP_ONGOING_TEXT, tooltip: CLEANUP_ONGOING_TOOLTIP }, [UNFINISHED_STATUS]: { text: CLEANUP_UNFINISHED_TEXT, tooltip: CLEANUP_UNFINISHED_TOOLTIP }, - }[this.image?.expirationPolicyCleanupStatus]; + }[this.imageDetails?.expirationPolicyCleanupStatus]; }, deleteButtonDisabled() { - return this.disabled || !this.image.canDelete; + return this.disabled || !this.imageDetails.canDelete; }, rootImageTooltip() { - return !this.image.name ? ROOT_IMAGE_TOOLTIP : ''; + return !this.imageDetails.name ? ROOT_IMAGE_TOOLTIP : ''; }, imageName() { - return this.image.name || ROOT_IMAGE_TEXT; + return this.imageDetails.name || ROOT_IMAGE_TEXT; }, }, }; </script> <template> - <title-area :metadata-loading="metadataLoading"> + <title-area> <template #title> <span data-testid="title"> {{ imageName }} @@ -124,13 +143,8 @@ export default { /> </template> <template #right-actions> - <gl-button - v-if="!metadataLoading" - variant="danger" - :disabled="deleteButtonDisabled" - @click="$emit('delete')" - > - {{ __('Delete') }} + <gl-button variant="danger" :disabled="deleteButtonDisabled" @click="$emit('delete')"> + {{ __('Delete image repository') }} </gl-button> </template> </title-area> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue index bc10246614a..3e19a646f53 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue @@ -1,19 +1,32 @@ <script> -import { GlButton } from '@gitlab/ui'; -import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE } from '../../constants/index'; +import { GlButton, GlKeysetPagination } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { + REMOVE_TAGS_BUTTON_TITLE, + TAGS_LIST_TITLE, + GRAPHQL_PAGE_SIZE, + FETCH_IMAGES_LIST_ERROR_MESSAGE, +} from '../../constants/index'; +import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql'; +import EmptyState from './empty_state.vue'; import TagsListRow from './tags_list_row.vue'; +import TagsLoader from './tags_loader.vue'; export default { name: 'TagsList', components: { GlButton, + GlKeysetPagination, TagsListRow, + EmptyState, + TagsLoader, }, + inject: ['config'], props: { - tags: { - type: Array, - required: false, - default: () => [], + id: { + type: [Number, String], + required: true, }, isMobile: { type: Boolean, @@ -25,17 +38,46 @@ export default { default: false, required: false, }, + isImageLoading: { + type: Boolean, + default: false, + required: false, + }, }, i18n: { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE, }, + apollo: { + containerRepository: { + query: getContainerRepositoryTagsQuery, + variables() { + return this.queryVariables; + }, + error() { + createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + }, + }, + }, data() { return { selectedItems: {}, + containerRepository: {}, }; }, computed: { + tags() { + return this.containerRepository?.tags?.nodes || []; + }, + tagsPageInfo() { + return this.containerRepository?.tags?.pageInfo; + }, + queryVariables() { + return { + id: joinPaths(this.config.gidPrefix, `${this.id}`), + first: GRAPHQL_PAGE_SIZE, + }; + }, hasSelectedItems() { return this.tags.some((tag) => this.selectedItems[tag.name]); }, @@ -45,42 +87,93 @@ export default { multiDeleteButtonIsDisabled() { return !this.hasSelectedItems || this.disabled; }, + showPagination() { + return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage; + }, + hasNoTags() { + return this.tags.length === 0; + }, + isLoading() { + return this.isImageLoading || this.$apollo.queries.containerRepository.loading; + }, }, methods: { updateSelectedItems(name) { this.$set(this.selectedItems, name, !this.selectedItems[name]); }, + mapTagsToBeDleeted(items) { + return this.tags.filter((tag) => items[tag.name]); + }, + fetchNextPage() { + this.$apollo.queries.containerRepository.fetchMore({ + variables: { + after: this.tagsPageInfo?.endCursor, + first: GRAPHQL_PAGE_SIZE, + }, + updateQuery(previousResult, { fetchMoreResult }) { + return fetchMoreResult; + }, + }); + }, + fetchPreviousPage() { + this.$apollo.queries.containerRepository.fetchMore({ + variables: { + first: null, + before: this.tagsPageInfo?.startCursor, + last: GRAPHQL_PAGE_SIZE, + }, + updateQuery(previousResult, { fetchMoreResult }) { + return fetchMoreResult; + }, + }); + }, }, }; </script> <template> <div> - <div class="gl-display-flex gl-justify-content-space-between gl-mb-3"> - <h5 data-testid="list-title"> - {{ $options.i18n.TAGS_LIST_TITLE }} - </h5> + <tags-loader v-if="isLoading" /> + <template v-else> + <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" /> + <template v-else> + <div class="gl-display-flex gl-justify-content-space-between gl-mb-3"> + <h5 data-testid="list-title"> + {{ $options.i18n.TAGS_LIST_TITLE }} + </h5> - <gl-button - v-if="showMultiDeleteButton" - :disabled="multiDeleteButtonIsDisabled" - category="secondary" - variant="danger" - @click="$emit('delete', selectedItems)" - > - {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }} - </gl-button> - </div> - <tags-list-row - v-for="(tag, index) in tags" - :key="tag.path" - :tag="tag" - :first="index === 0" - :selected="selectedItems[tag.name]" - :is-mobile="isMobile" - :disabled="disabled" - @select="updateSelectedItems(tag.name)" - @delete="$emit('delete', { [tag.name]: true })" - /> + <gl-button + v-if="showMultiDeleteButton" + :disabled="multiDeleteButtonIsDisabled" + category="secondary" + variant="danger" + @click="$emit('delete', mapTagsToBeDleeted(selectedItems))" + > + {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }} + </gl-button> + </div> + <tags-list-row + v-for="(tag, index) in tags" + :key="tag.path" + :tag="tag" + :first="index === 0" + :selected="selectedItems[tag.name]" + :is-mobile="isMobile" + :disabled="disabled" + @select="updateSelectedItems(tag.name)" + @delete="$emit('delete', mapTagsToBeDleeted({ [tag.name]: true }))" + /> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + :has-next-page="tagsPageInfo.hasNextPage" + :has-previous-page="tagsPageInfo.hasPreviousPage" + class="gl-mt-3" + @prev="fetchPreviousPage" + @next="fetchNextPage" + /> + </div> + </template> + </template> </div> </template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue index 74027a376a7..45eb2ce51e4 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -50,6 +50,11 @@ export default { default: false, required: false, }, + disabled: { + type: Boolean, + default: false, + required: false, + }, }, i18n: { REMOVE_TAG_BUTTON_TITLE, @@ -92,19 +97,25 @@ export default { tagLocation() { return this.tag.path?.replace(`:${this.tag.name}`, ''); }, - invalidTag() { + isInvalidTag() { return !this.tag.digest; }, + isCheckboxDisabled() { + return this.isInvalidTag || this.disabled; + }, + isDeleteDisabled() { + return this.isInvalidTag || this.disabled || !this.tag.canDelete; + }, }, }; </script> <template> - <list-item v-bind="$attrs" :selected="selected"> + <list-item v-bind="$attrs" :selected="selected" :disabled="disabled"> <template #left-action> <gl-form-checkbox v-if="tag.canDelete" - :disabled="invalidTag" + :disabled="isCheckboxDisabled" class="gl-m-0" :checked="selected" @change="$emit('select')" @@ -126,10 +137,11 @@ export default { :title="tag.location" :text="tag.location" category="tertiary" + :disabled="disabled" /> <gl-icon - v-if="invalidTag" + v-if="isInvalidTag" v-gl-tooltip="{ title: $options.i18n.MISSING_MANIFEST_WARNING_TOOLTIP }" name="warning" class="gl-text-orange-500 gl-mb-2 gl-ml-2" @@ -162,7 +174,7 @@ export default { </template> <template #right-action> <delete-button - :disabled="!tag.canDelete || invalidTag" + :disabled="isDeleteDisabled" :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" :tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP" :tooltip-disabled="tag.canDelete" @@ -172,7 +184,7 @@ export default { /> </template> - <template v-if="!invalidTag" #details-published> + <template v-if="!isInvalidTag" #details-published> <details-row icon="clock" data-testid="published-date-detail"> <gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT"> <template #repositoryPath> @@ -187,7 +199,7 @@ export default { </gl-sprintf> </details-row> </template> - <template v-if="!invalidTag" #details-manifest-digest> + <template v-if="!isInvalidTag" #details-manifest-digest> <details-row icon="log" data-testid="manifest-detail"> <gl-sprintf :message="$options.i18n.MANIFEST_DETAILS_ROW_TEST"> <template #digest> @@ -200,10 +212,11 @@ export default { :text="tag.digest" category="tertiary" size="small" + :disabled="disabled" /> </details-row> </template> - <template v-if="!invalidTag" #details-configuration-digest> + <template v-if="!isInvalidTag" #details-configuration-digest> <details-row icon="cloud-gear" data-testid="configuration-detail"> <gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST"> <template #digest> @@ -216,6 +229,7 @@ export default { :text="formattedRevision" category="tertiary" size="small" + :disabled="disabled" /> </details-row> </template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue index 0373a84b271..930ad01c758 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue @@ -78,6 +78,9 @@ export default { imageName() { return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`; }, + routerLinkEvent() { + return this.deleting ? '' : 'click'; + }, }, }; </script> @@ -97,6 +100,7 @@ export default { class="gl-text-body gl-font-weight-bold" data-testid="details-link" data-qa-selector="registry_image_content" + :event="routerLinkEvent" :to="{ name: 'details', params: { id } }" > {{ imageName }} diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index 7220f9646db..5dcc042a9c4 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -31,7 +31,7 @@ export const CONFIGURATION_DETAILS_ROW_TEST = s__( ); export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); -export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected'); +export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected tags'); export const REMOVE_TAG_CONFIRMATION_TEXT = s__( `ContainerRegistry|You are about to remove %{item}. Are you sure?`, diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql index 3fd019467ac..88c2e667afd 100644 --- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql @@ -1,12 +1,4 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" - -query getContainerRepositoryDetails( - $id: ID! - $first: Int - $last: Int - $after: String - $before: String -) { +query getContainerRepositoryDetails($id: ID!) { containerRepository(id: $id) { id name @@ -16,25 +8,8 @@ query getContainerRepositoryDetails( canDelete createdAt updatedAt - tagsCount expirationPolicyStartedAt expirationPolicyCleanupStatus - tags(after: $after, before: $before, first: $first, last: $last) { - nodes { - digest - location - path - name - revision - shortRevision - createdAt - totalSize - canDelete - } - pageInfo { - ...PageInfo - } - } project { visibility containerExpirationPolicy { diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql new file mode 100644 index 00000000000..a703c2dd0ac --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql @@ -0,0 +1,29 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getContainerRepositoryTags( + $id: ID! + $first: Int + $last: Int + $after: String + $before: String +) { + containerRepository(id: $id) { + id + tags(after: $after, before: $before, first: $first, last: $last) { + nodes { + digest + location + path + name + revision + shortRevision + createdAt + totalSize + canDelete + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql new file mode 100644 index 00000000000..9092a71edb0 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql @@ -0,0 +1,6 @@ +query getContainerRepositoryTagsCount($id: ID!) { + containerRepository(id: $id) { + id + tagsCount + } +} diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 2f515356fa7..34ec3b085a5 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -1,5 +1,5 @@ <script> -import { GlKeysetPagination, GlResizeObserverDirective } from '@gitlab/ui'; +import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; @@ -21,7 +21,6 @@ import { ALERT_SUCCESS_TAGS, ALERT_DANGER_TAGS, ALERT_DANGER_IMAGE, - GRAPHQL_PAGE_SIZE, FETCH_IMAGES_LIST_ERROR_MESSAGE, UNFINISHED_STATUS, MISSING_OR_DELETED_IMAGE_BREADCRUMB, @@ -36,7 +35,6 @@ export default { DeleteAlert, PartialCleanupAlert, DetailsHeader, - GlKeysetPagination, DeleteModal, TagsList, TagsLoader, @@ -50,16 +48,12 @@ export default { mixins: [Tracking.mixin()], inject: ['breadCrumbState', 'config'], apollo: { - image: { + containerRepository: { query: getContainerRepositoryDetailsQuery, variables() { return this.queryVariables; }, - update(data) { - return data.containerRepository; - }, - result({ data }) { - this.tagsPageInfo = data.containerRepository?.tags?.pageInfo; + result() { this.updateBreadcrumb(); }, error() { @@ -69,8 +63,7 @@ export default { }, data() { return { - image: {}, - tagsPageInfo: {}, + containerRepository: {}, itemsToBeDeleted: [], isMobile: false, mutationLoading: false, @@ -83,19 +76,15 @@ export default { queryVariables() { return { id: joinPaths(this.config.gidPrefix, `${this.$route.params.id}`), - first: GRAPHQL_PAGE_SIZE, }; }, isLoading() { - return this.$apollo.queries.image.loading || this.mutationLoading; - }, - tags() { - return this.image?.tags?.nodes || []; + return this.$apollo.queries.containerRepository.loading || this.mutationLoading; }, showPartialCleanupWarning() { return ( this.config.showUnfinishedTagCleanupCallout && - this.image?.expirationPolicyCleanupStatus === UNFINISHED_STATUS && + this.containerRepository?.expirationPolicyCleanupStatus === UNFINISHED_STATUS && !this.hidePartialCleanupWarning ); }, @@ -105,26 +94,20 @@ export default { this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete', }; }, - showPagination() { - return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage; - }, - hasNoTags() { - return this.tags.length === 0; - }, pageActionsAreDisabled() { - return Boolean(this.image?.status); + return Boolean(this.containerRepository?.status); }, }, methods: { updateBreadcrumb() { - const name = this.image?.id - ? this.image?.name || ROOT_IMAGE_TEXT + const name = this.containerRepository?.id + ? this.containerRepository?.name || ROOT_IMAGE_TEXT : MISSING_OR_DELETED_IMAGE_BREADCRUMB; this.breadCrumbState.updateName(name); }, deleteTags(toBeDeleted) { this.deleteImageAlert = false; - this.itemsToBeDeleted = this.tags.filter((tag) => toBeDeleted[tag.name]); + this.itemsToBeDeleted = toBeDeleted; this.track('click_button'); this.$refs.deleteModal.show(); }, @@ -170,33 +153,6 @@ export default { handleResize() { this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs'; }, - fetchNextPage() { - if (this.tagsPageInfo?.hasNextPage) { - this.$apollo.queries.image.fetchMore({ - variables: { - after: this.tagsPageInfo?.endCursor, - first: GRAPHQL_PAGE_SIZE, - }, - updateQuery(previousResult, { fetchMoreResult }) { - return fetchMoreResult; - }, - }); - } - }, - fetchPreviousPage() { - if (this.tagsPageInfo?.hasPreviousPage) { - this.$apollo.queries.image.fetchMore({ - variables: { - first: null, - before: this.tagsPageInfo?.startCursor, - last: GRAPHQL_PAGE_SIZE, - }, - updateQuery(previousResult, { fetchMoreResult }) { - return fetchMoreResult; - }, - }); - } - }, dismissPartialCleanupWarning() { this.hidePartialCleanupWarning = true; axios.post(this.config.userCalloutsPath, { @@ -205,7 +161,7 @@ export default { }, deleteImage() { this.deleteImageAlert = true; - this.itemsToBeDeleted = [{ path: this.image.path }]; + this.itemsToBeDeleted = [{ path: this.containerRepository.path }]; this.$refs.deleteModal.show(); }, deleteImageError() { @@ -221,7 +177,7 @@ export default { <template> <div v-gl-resize-observer="handleResize" class="gl-my-3"> - <template v-if="image"> + <template v-if="containerRepository"> <delete-alert v-model="deleteAlertType" :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" @@ -236,40 +192,27 @@ export default { @dismiss="dismissPartialCleanupWarning" /> - <status-alert v-if="image.status" :status="image.status" /> + <status-alert v-if="containerRepository.status" :status="containerRepository.status" /> <details-header - :image="image" - :metadata-loading="isLoading" + v-if="!isLoading" + :image="containerRepository" :disabled="pageActionsAreDisabled" @delete="deleteImage" /> <tags-loader v-if="isLoading" /> - <template v-else> - <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" /> - <template v-else> - <tags-list - :tags="tags" - :is-mobile="isMobile" - :disabled="pageActionsAreDisabled" - @delete="deleteTags" - /> - <div class="gl-display-flex gl-justify-content-center"> - <gl-keyset-pagination - v-if="showPagination" - :has-next-page="tagsPageInfo.hasNextPage" - :has-previous-page="tagsPageInfo.hasPreviousPage" - class="gl-mt-3" - @prev="fetchPreviousPage" - @next="fetchNextPage" - /> - </div> - </template> - </template> + <tags-list + v-else + :id="$route.params.id" + :is-image-loading="isLoading" + :is-mobile="isMobile" + :disabled="pageActionsAreDisabled" + @delete="deleteTags" + /> <delete-image - :id="image.id" + :id="containerRepository.id" ref="deleteImage" use-update-fn @start="deleteImageIniit" diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index 262b5614d65..31d335fa15d 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -51,12 +51,8 @@ export default { }), fetchReleases() { this.fetchReleasesStoreAction({ - // these two parameters are only used in "GraphQL mode" before: getParameterByName('before'), after: getParameterByName('after'), - - // this parameter is only used when in "REST mode" - page: getParameterByName('page'), }); }, }, @@ -73,17 +69,17 @@ export default { :aria-describedby="shouldRenderEmptyState && 'releases-description'" category="primary" variant="success" - class="js-new-release-btn" + data-testid="new-release-button" > {{ __('New release') }} </gl-button> </div> - <release-skeleton-loader v-if="isLoading" class="js-loading" /> + <release-skeleton-loader v-if="isLoading" /> <gl-empty-state v-else-if="shouldRenderEmptyState" - class="js-empty-state" + data-testid="empty-state" :title="__('Getting started with releases')" :svg-path="illustrationPath" > @@ -101,7 +97,7 @@ export default { </template> </gl-empty-state> - <div v-else-if="shouldRenderSuccessState" class="js-success-state"> + <div v-else-if="shouldRenderSuccessState" data-testid="success-state"> <release-block v-for="(release, index) in releases" :key="index" diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue index c38e93d420b..fdb0f99b735 100644 --- a/app/assets/javascripts/releases/components/app_show.vue +++ b/app/assets/javascripts/releases/components/app_show.vue @@ -1,7 +1,7 @@ <script> import createFlash from '~/flash'; import { s__ } from '~/locale'; -import oneReleaseQuery from '../queries/one_release.query.graphql'; +import oneReleaseQuery from '../graphql/queries/one_release.query.graphql'; import { convertGraphQLRelease } from '../util'; import ReleaseBlock from './release_block.vue'; import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; diff --git a/app/assets/javascripts/releases/components/releases_pagination.vue b/app/assets/javascripts/releases/components/releases_pagination.vue index 062c72b445b..fddf85ead1e 100644 --- a/app/assets/javascripts/releases/components/releases_pagination.vue +++ b/app/assets/javascripts/releases/components/releases_pagination.vue @@ -1,20 +1,37 @@ <script> -import { mapGetters } from 'vuex'; -import ReleasesPaginationGraphql from './releases_pagination_graphql.vue'; -import ReleasesPaginationRest from './releases_pagination_rest.vue'; +import { GlKeysetPagination } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; export default { - name: 'ReleasesPagination', - components: { ReleasesPaginationGraphql, ReleasesPaginationRest }, + name: 'ReleasesPaginationGraphql', + components: { GlKeysetPagination }, computed: { - ...mapGetters(['useGraphQLEndpoint']), + ...mapState('index', ['pageInfo']), + showPagination() { + return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; + }, + }, + methods: { + ...mapActions('index', ['fetchReleases']), + onPrev(before) { + historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); + this.fetchReleases({ before }); + }, + onNext(after) { + historyPushState(buildUrlWithCurrentLocation(`?after=${after}`)); + this.fetchReleases({ after }); + }, }, }; </script> - <template> <div class="gl-display-flex gl-justify-content-center"> - <releases-pagination-graphql v-if="useGraphQLEndpoint" /> - <releases-pagination-rest v-else /> + <gl-keyset-pagination + v-if="showPagination" + v-bind="pageInfo" + @prev="onPrev($event)" + @next="onNext($event)" + /> </div> </template> diff --git a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue deleted file mode 100644 index 13cbf95b9af..00000000000 --- a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue +++ /dev/null @@ -1,35 +0,0 @@ -<script> -import { GlKeysetPagination } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; -import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; - -export default { - name: 'ReleasesPaginationGraphql', - components: { GlKeysetPagination }, - computed: { - ...mapState('index', ['graphQlPageInfo']), - showPagination() { - return this.graphQlPageInfo.hasPreviousPage || this.graphQlPageInfo.hasNextPage; - }, - }, - methods: { - ...mapActions('index', ['fetchReleases']), - onPrev(before) { - historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); - this.fetchReleases({ before }); - }, - onNext(after) { - historyPushState(buildUrlWithCurrentLocation(`?after=${after}`)); - this.fetchReleases({ after }); - }, - }, -}; -</script> -<template> - <gl-keyset-pagination - v-if="showPagination" - v-bind="graphQlPageInfo" - @prev="onPrev($event)" - @next="onNext($event)" - /> -</template> diff --git a/app/assets/javascripts/releases/components/releases_pagination_rest.vue b/app/assets/javascripts/releases/components/releases_pagination_rest.vue deleted file mode 100644 index 5e97a5a0450..00000000000 --- a/app/assets/javascripts/releases/components/releases_pagination_rest.vue +++ /dev/null @@ -1,24 +0,0 @@ -<script> -import { mapActions, mapState } from 'vuex'; -import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; -import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; - -export default { - name: 'ReleasesPaginationRest', - components: { TablePagination }, - computed: { - ...mapState('index', ['restPageInfo']), - }, - methods: { - ...mapActions('index', ['fetchReleases']), - onChangePage(page) { - historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); - this.fetchReleases({ page }); - }, - }, -}; -</script> - -<template> - <table-pagination :change="onChangePage" :page-info="restPageInfo" /> -</template> diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index 9df646ca798..80f59485426 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -74,6 +74,21 @@ export default { // we need to show the "create from" input. this.showCreateFrom = true; }, + shouldShowCreateTagOption(isLoading, matches, query) { + // Show the "create tag" option if: + return ( + // we're not currently loading any results, and + !isLoading && + // the search query isn't just whitespace, and + query.trim() && + // the `matches` object is non-null, and + matches && + // the tag name doesn't already exist + !matches.tags.list.some( + (tagInfo) => tagInfo.name.toUpperCase() === query.toUpperCase().trim(), + ) + ); + }, }, translations: { tagName: { @@ -111,7 +126,7 @@ export default { > <template #footer="{ isLoading, matches, query }"> <gl-dropdown-item - v-if="!isLoading && matches && matches.tags.totalCount === 0" + v-if="shouldShowCreateTagOption(isLoading, matches, query)" is-check-item :is-checked="tagName === query" @click="createTagClicked(query)" diff --git a/app/assets/javascripts/releases/queries/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql index 3a742db7d9e..3a742db7d9e 100644 --- a/app/assets/javascripts/releases/queries/release.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql new file mode 100644 index 00000000000..47c5afefd78 --- /dev/null +++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql @@ -0,0 +1,23 @@ +fragment ReleaseForEditing on Release { + name + tagName + description + assets { + links { + nodes { + id + name + url + linkType + } + } + } + links { + selfUrl + } + milestones { + nodes { + title + } + } +} diff --git a/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql new file mode 100644 index 00000000000..56bfe7c23d6 --- /dev/null +++ b/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql @@ -0,0 +1,10 @@ +mutation createRelease($input: ReleaseCreateInput!) { + releaseCreate(input: $input) { + release { + links { + selfUrl + } + } + errors + } +} diff --git a/app/assets/javascripts/releases/graphql/mutations/create_release_link.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/create_release_link.mutation.graphql new file mode 100644 index 00000000000..4bdfc79dbc4 --- /dev/null +++ b/app/assets/javascripts/releases/graphql/mutations/create_release_link.mutation.graphql @@ -0,0 +1,5 @@ +mutation createReleaseAssetLink($input: ReleaseAssetLinkCreateInput!) { + releaseAssetLinkCreate(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/releases/graphql/mutations/delete_release_link.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/delete_release_link.mutation.graphql new file mode 100644 index 00000000000..a75eddcd288 --- /dev/null +++ b/app/assets/javascripts/releases/graphql/mutations/delete_release_link.mutation.graphql @@ -0,0 +1,5 @@ +mutation deleteReleaseAssetLink($input: ReleaseAssetLinkDeleteInput!) { + releaseAssetLinkDelete(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/releases/graphql/mutations/update_release.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/update_release.mutation.graphql new file mode 100644 index 00000000000..9c6a861d2f1 --- /dev/null +++ b/app/assets/javascripts/releases/graphql/mutations/update_release.mutation.graphql @@ -0,0 +1,5 @@ +mutation updateRelease($input: ReleaseUpdateInput!) { + releaseUpdate(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/releases/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql index a07dabb9fd6..10e4d883e62 100644 --- a/app/assets/javascripts/releases/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql @@ -1,4 +1,4 @@ -#import "./release.fragment.graphql" +#import "../fragments/release.fragment.graphql" query allReleases( $fullPath: ID! diff --git a/app/assets/javascripts/releases/queries/one_release.query.graphql b/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql index b893aea94b0..c80d6e753ab 100644 --- a/app/assets/javascripts/releases/queries/one_release.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql @@ -1,4 +1,4 @@ -#import "./release.fragment.graphql" +#import "../fragments/release.fragment.graphql" query oneRelease($fullPath: ID!, $tagName: String!) { project(fullPath: $fullPath) { diff --git a/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql b/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql new file mode 100644 index 00000000000..767ba4aeca0 --- /dev/null +++ b/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql @@ -0,0 +1,9 @@ +#import "../fragments/release_for_editing.fragment.graphql" + +query oneReleaseForEditing($fullPath: ID!, $tagName: String!) { + project(fullPath: $fullPath) { + release(tagName: $tagName) { + ...ReleaseForEditing + } + } +} diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js index 0b453467c13..bb21ec7c43f 100644 --- a/app/assets/javascripts/releases/mount_index.js +++ b/app/assets/javascripts/releases/mount_index.js @@ -15,11 +15,6 @@ export default () => { modules: { index: createIndexModule(el.dataset), }, - featureFlags: { - graphqlReleaseData: Boolean(gon.features?.graphqlReleaseData), - graphqlReleasesPage: Boolean(gon.features?.graphqlReleasesPage), - graphqlMilestoneStats: Boolean(gon.features?.graphqlMilestoneStats), - }, }), render: (h) => h(ReleaseIndexApp), }); diff --git a/app/assets/javascripts/releases/stores/getters.js b/app/assets/javascripts/releases/stores/getters.js deleted file mode 100644 index 2a06f398e26..00000000000 --- a/app/assets/javascripts/releases/stores/getters.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @returns {Boolean} `true` if all the feature flags - * required to enable the GraphQL endpoint are enabled - */ -export const useGraphQLEndpoint = (rootState) => { - return Boolean( - rootState.featureFlags.graphqlReleaseData && - rootState.featureFlags.graphqlReleasesPage && - rootState.featureFlags.graphqlMilestoneStats, - ); -}; diff --git a/app/assets/javascripts/releases/stores/index.js b/app/assets/javascripts/releases/stores/index.js index cc8b586964f..b2e93d789d7 100644 --- a/app/assets/javascripts/releases/stores/index.js +++ b/app/assets/javascripts/releases/stores/index.js @@ -1,9 +1,7 @@ import Vuex from 'vuex'; -import * as getters from './getters'; export default ({ modules, featureFlags }) => new Vuex.Store({ modules, state: { featureFlags }, - getters, }); diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js index 8dc2083dd2b..b312c2a7506 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -1,14 +1,12 @@ -import api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; -import oneReleaseQuery from '~/releases/queries/one_release.query.graphql'; -import { - releaseToApiJson, - apiJsonToRelease, - gqClient, - convertOneReleaseGraphQLResponse, -} from '~/releases/util'; +import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql'; +import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql'; +import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql'; +import updateReleaseMutation from '~/releases/graphql/mutations/update_release.mutation.graphql'; +import oneReleaseForEditingQuery from '~/releases/graphql/queries/one_release_for_editing.query.graphql'; +import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util'; import * as types from './mutation_types'; export const initializeRelease = ({ commit, dispatch, getters }) => { @@ -24,38 +22,25 @@ export const initializeRelease = ({ commit, dispatch, getters }) => { return Promise.resolve(); }; -export const fetchRelease = ({ commit, state, rootState }) => { +export const fetchRelease = async ({ commit, state }) => { commit(types.REQUEST_RELEASE); - if (rootState.featureFlags?.graphqlIndividualReleasePage) { - return gqClient - .query({ - query: oneReleaseQuery, - variables: { - fullPath: state.projectPath, - tagName: state.tagName, - }, - }) - .then((response) => { - const { data: release } = convertOneReleaseGraphQLResponse(response); - - commit(types.RECEIVE_RELEASE_SUCCESS, release); - }) - .catch((error) => { - commit(types.RECEIVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while getting the release details.')); - }); - } - - return api - .release(state.projectId, state.tagName) - .then(({ data }) => { - commit(types.RECEIVE_RELEASE_SUCCESS, apiJsonToRelease(data)); - }) - .catch((error) => { - commit(types.RECEIVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while getting the release details.')); + try { + const fetchResponse = await gqClient.query({ + query: oneReleaseForEditingQuery, + variables: { + fullPath: state.projectPath, + tagName: state.tagName, + }, }); + + const { data: release } = convertOneReleaseGraphQLResponse(fetchResponse); + + commit(types.RECEIVE_RELEASE_SUCCESS, release); + } catch (error) { + commit(types.RECEIVE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while getting the release details.')); + } }; export const updateReleaseTagName = ({ commit }, tagName) => @@ -94,9 +79,9 @@ export const removeAssetLink = ({ commit }, linkIdToRemove) => { commit(types.REMOVE_ASSET_LINK, linkIdToRemove); }; -export const receiveSaveReleaseSuccess = ({ commit }, release) => { +export const receiveSaveReleaseSuccess = ({ commit }, urlToRedirectTo) => { commit(types.RECEIVE_SAVE_RELEASE_SUCCESS); - redirectTo(release._links.self); + redirectTo(urlToRedirectTo); }; export const saveRelease = ({ commit, dispatch, getters }) => { @@ -105,83 +90,130 @@ export const saveRelease = ({ commit, dispatch, getters }) => { dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease'); }; -export const createRelease = ({ commit, dispatch, state, getters }) => { - const apiJson = releaseToApiJson( - { - ...state.release, - assets: { - links: getters.releaseLinksToCreate, - }, - }, - state.createFrom, - ); +/** + * Tests a GraphQL mutation response for the existence of any errors-as-data + * (See https://docs.gitlab.com/ee/development/fe_guide/graphql.html#errors-as-data). + * If any errors occurred, throw a JavaScript `Error` object, so that this can be + * handled by the global error handler. + * + * @param {Object} gqlResponse The response object returned by the GraphQL client + * @param {String} mutationName The name of the mutation that was executed + * @param {String} messageIfError An message to build into the error object if something went wrong + */ +const checkForErrorsAsData = (gqlResponse, mutationName, messageIfError) => { + const allErrors = gqlResponse.data[mutationName].errors; + if (allErrors.length > 0) { + const allErrorMessages = JSON.stringify(allErrors); + throw new Error(`${messageIfError}: ${allErrorMessages}`); + } +}; - return api - .createRelease(state.projectId, apiJson) - .then(({ data }) => { - dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(data)); - }) - .catch((error) => { - commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while creating a new release')); +export const createRelease = async ({ commit, dispatch, state, getters }) => { + try { + const response = await gqClient.mutate({ + mutation: createReleaseMutation, + variables: getters.releaseCreateMutatationVariables, }); + + checkForErrorsAsData( + response, + 'releaseCreate', + `Something went wrong while creating a new release with projectPath "${state.projectPath}" and tagName "${state.release.tagName}"`, + ); + + dispatch('receiveSaveReleaseSuccess', response.data.releaseCreate.release.links.selfUrl); + } catch (error) { + commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while creating a new release.')); + } }; -export const updateRelease = ({ commit, dispatch, state, getters }) => { - const apiJson = releaseToApiJson({ - ...state.release, - assets: { - links: getters.releaseLinksToCreate, +/** + * Deletes a single release link. + * Throws an error if any network or validation errors occur. + */ +const deleteReleaseLinks = async ({ state, id }) => { + const deleteResponse = await gqClient.mutate({ + mutation: deleteReleaseAssetLinkMutation, + variables: { + input: { id }, }, }); - let updatedRelease = null; - - return ( - api - .updateRelease(state.projectId, state.tagName, apiJson) - - /** - * Currently, we delete all existing links and then - * recreate new ones on each edit. This is because the - * REST API doesn't support bulk updating of Release links, - * and updating individual links can lead to validation - * race conditions (in particular, the "URLs must be unique") - * constraint. - * - * This isn't ideal since this is no longer an atomic - * operation - parts of it can fail while others succeed, - * leaving the Release in an inconsistent state. - * - * This logic should be refactored to use GraphQL once - * https://gitlab.com/gitlab-org/gitlab/-/issues/208702 - * is closed. - */ - .then(({ data }) => { - // Save this response since we need it later in the Promise chain - updatedRelease = data; - - // Delete all links currently associated with this Release - return Promise.all( - getters.releaseLinksToDelete.map((l) => - api.deleteReleaseLink(state.projectId, state.release.tagName, l.id), - ), - ); - }) - .then(() => { - // Create a new link for each link in the form - return Promise.all( - apiJson.assets.links.map((l) => - api.createReleaseLink(state.projectId, state.release.tagName, l), - ), - ); - }) - .then(() => { - dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease)); - }) - .catch((error) => { - commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while saving the release details')); - }) + checkForErrorsAsData( + deleteResponse, + 'releaseAssetLinkDelete', + `Something went wrong while deleting release asset link for release with projectPath "${state.projectPath}", tagName "${state.tagName}", and link id "${id}"`, ); }; + +/** + * Creates a single release link. + * Throws an error if any network or validation errors occur. + */ +const createReleaseLink = async ({ state, link }) => { + const createResponse = await gqClient.mutate({ + mutation: createReleaseAssetLinkMutation, + variables: { + input: { + projectPath: state.projectPath, + tagName: state.tagName, + name: link.name, + url: link.url, + linkType: link.linkType.toUpperCase(), + }, + }, + }); + + checkForErrorsAsData( + createResponse, + 'releaseAssetLinkCreate', + `Something went wrong while creating a release asset link for release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`, + ); +}; + +export const updateRelease = async ({ commit, dispatch, state, getters }) => { + try { + /** + * Currently, we delete all existing links and then + * recreate new ones on each edit. This is because the + * backend doesn't support bulk updating of Release links, + * and updating individual links can lead to validation + * race conditions (in particular, the "URLs must be unique") + * constraint. + * + * This isn't ideal since this is no longer an atomic + * operation - parts of it can fail while others succeed, + * leaving the Release in an inconsistent state. + * + * This logic should be refactored to take place entirely + * in the backend. This is being discussed in + * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50300 + */ + const updateReleaseResponse = await gqClient.mutate({ + mutation: updateReleaseMutation, + variables: getters.releaseUpdateMutatationVariables, + }); + + checkForErrorsAsData( + updateReleaseResponse, + 'releaseUpdate', + `Something went wrong while updating release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`, + ); + + // Delete all links currently associated with this Release + await Promise.all( + getters.releaseLinksToDelete.map(({ id }) => deleteReleaseLinks({ state, id })), + ); + + // Create a new link for each link in the form + await Promise.all( + getters.releaseLinksToCreate.map((link) => createReleaseLink({ state, link })), + ); + + dispatch('receiveSaveReleaseSuccess', state.release._links.self); + } catch (error) { + commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while saving the release details.')); + } +}; diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js index 831037c8861..d83ec05872a 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js @@ -103,3 +103,39 @@ export const isValid = (_state, getters) => { const errors = getters.validationErrors; return Object.values(errors.assets.links).every(isEmpty) && !errors.isTagNameEmpty; }; + +/** Returns all the variables for a `releaseUpdate` GraphQL mutation */ +export const releaseUpdateMutatationVariables = (state) => { + const name = state.release.name?.trim().length > 0 ? state.release.name.trim() : null; + + // Milestones may be either a list of milestone objects OR just a list + // of milestone titles. The GraphQL mutation requires only the titles be sent. + const milestones = (state.release.milestones || []).map((m) => m.title || m); + + return { + input: { + projectPath: state.projectPath, + tagName: state.release.tagName, + name, + description: state.release.description, + milestones, + }, + }; +}; + +/** Returns all the variables for a `releaseCreate` GraphQL mutation */ +export const releaseCreateMutatationVariables = (state, getters) => { + return { + input: { + ...getters.releaseUpdateMutatationVariables.input, + ref: state.createFrom, + assets: { + links: getters.releaseLinksToCreate.map(({ name, url, linkType }) => ({ + name, + url, + linkType: linkType.toUpperCase(), + })), + }, + }, + }; +}; diff --git a/app/assets/javascripts/releases/stores/modules/index/actions.js b/app/assets/javascripts/releases/stores/modules/index/actions.js index f1add54626a..00be25f089b 100644 --- a/app/assets/javascripts/releases/stores/modules/index/actions.js +++ b/app/assets/javascripts/releases/stores/modules/index/actions.js @@ -1,45 +1,21 @@ -import api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import { - normalizeHeaders, - parseIntPagination, - convertObjectPropsToCamelCase, -} from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import allReleasesQuery from '~/releases/queries/all_releases.query.graphql'; -import { PAGE_SIZE } from '../../../constants'; -import { gqClient, convertAllReleasesGraphQLResponse } from '../../../util'; +import { PAGE_SIZE } from '~/releases/constants'; +import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; +import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util'; import * as types from './mutation_types'; /** - * Gets a paginated list of releases from the server + * Gets a paginated list of releases from the GraphQL endpoint * * @param {Object} vuexParams * @param {Object} actionParams - * @param {Number} [actionParams.page] The page number of results to fetch - * (this parameter is only used when fetching results from the REST API) * @param {String} [actionParams.before] A GraphQL cursor. If provided, - * the items returned will proceed the provided cursor (this parameter is only - * used when fetching results from the GraphQL API). + * the items returned will proceed the provided cursor. * @param {String} [actionParams.after] A GraphQL cursor. If provided, - * the items returned will follow the provided cursor (this parameter is only - * used when fetching results from the GraphQL API). + * the items returned will follow the provided cursor. */ -export const fetchReleases = ({ dispatch, rootGetters }, { page = 1, before, after }) => { - if (rootGetters.useGraphQLEndpoint) { - dispatch('fetchReleasesGraphQl', { before, after }); - } else { - dispatch('fetchReleasesRest', { page }); - } -}; - -/** - * Gets a paginated list of releases from the GraphQL endpoint - */ -export const fetchReleasesGraphQl = ( - { dispatch, commit, state }, - { before = null, after = null }, -) => { +export const fetchReleases = ({ dispatch, commit, state }, { before, after }) => { commit(types.REQUEST_RELEASES); const { sort, orderBy } = state.sorting; @@ -55,7 +31,7 @@ export const fetchReleasesGraphQl = ( paginationParams = { first: PAGE_SIZE, after }; } else { throw new Error( - 'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.', + 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.', ); } @@ -69,33 +45,11 @@ export const fetchReleasesGraphQl = ( }, }) .then((response) => { - const { data, paginationInfo: graphQlPageInfo } = convertAllReleasesGraphQLResponse(response); + const { data, paginationInfo: pageInfo } = convertAllReleasesGraphQLResponse(response); commit(types.RECEIVE_RELEASES_SUCCESS, { data, - graphQlPageInfo, - }); - }) - .catch(() => dispatch('receiveReleasesError')); -}; - -/** - * Gets a paginated list of releases from the REST endpoint - */ -export const fetchReleasesRest = ({ dispatch, commit, state }, { page }) => { - commit(types.REQUEST_RELEASES); - - const { sort, orderBy } = state.sorting; - - api - .releases(state.projectId, { page, sort, order_by: orderBy }) - .then(({ data, headers }) => { - const restPageInfo = parseIntPagination(normalizeHeaders(headers)); - const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true }); - - commit(types.RECEIVE_RELEASES_SUCCESS, { - data: camelCasedReleases, - restPageInfo, + pageInfo, }); }) .catch(() => dispatch('receiveReleasesError')); diff --git a/app/assets/javascripts/releases/stores/modules/index/mutations.js b/app/assets/javascripts/releases/stores/modules/index/mutations.js index e1aaa2e2a19..55a8a488be8 100644 --- a/app/assets/javascripts/releases/stores/modules/index/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/index/mutations.js @@ -17,12 +17,11 @@ export default { * @param {Object} state * @param {Object} resp */ - [types.RECEIVE_RELEASES_SUCCESS](state, { data, restPageInfo, graphQlPageInfo }) { + [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) { state.hasError = false; state.isLoading = false; state.releases = data; - state.restPageInfo = restPageInfo; - state.graphQlPageInfo = graphQlPageInfo; + state.pageInfo = pageInfo; }, /** @@ -36,8 +35,7 @@ export default { state.isLoading = false; state.releases = []; state.hasError = true; - state.restPageInfo = {}; - state.graphQlPageInfo = {}; + state.pageInfo = {}; }, [types.SET_SORTING](state, sorting) { diff --git a/app/assets/javascripts/releases/stores/modules/index/state.js b/app/assets/javascripts/releases/stores/modules/index/state.js index 164a496d450..5e1aaab7b58 100644 --- a/app/assets/javascripts/releases/stores/modules/index/state.js +++ b/app/assets/javascripts/releases/stores/modules/index/state.js @@ -16,8 +16,7 @@ export default ({ isLoading: false, hasError: false, releases: [], - restPageInfo: {}, - graphQlPageInfo: {}, + pageInfo: {}, sorting: { sort: DESCENDING_ORDER, orderBy: RELEASED_AT, diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js index 36c17b5b252..22d5fb4f620 100644 --- a/app/assets/javascripts/releases/util.js +++ b/app/assets/javascripts/releases/util.js @@ -1,50 +1,7 @@ import { pick } from 'lodash'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; -import { - convertObjectPropsToCamelCase, - convertObjectPropsToSnakeCase, -} from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; -/** - * Converts a release object into a JSON object that can sent to the public - * API to create or update a release. - * @param {Object} release The release object to convert - * @param {string} createFrom The ref to create a new tag from, if necessary - */ -export const releaseToApiJson = (release, createFrom = null) => { - const name = release.name?.trim().length > 0 ? release.name.trim() : null; - - // Milestones may be either a list of milestone objects OR just a list - // of milestone titles. The API requires only the titles be sent. - const milestones = (release.milestones || []).map((m) => m.title || m); - - return convertObjectPropsToSnakeCase( - { - name, - tagName: release.tagName, - ref: createFrom, - description: release.description, - milestones, - assets: release.assets, - }, - { deep: true }, - ); -}; - -/** - * Converts a JSON release object returned by the Release API - * into the structure this Vue application can work with. - * @param {Object} json The JSON object received from the release API - */ -export const apiJsonToRelease = (json) => { - const release = convertObjectPropsToCamelCase(json, { deep: true }); - - release.milestones = release.milestones || []; - - return release; -}; - export const gqClient = createGqClient({}, { fetchPolicy: fetchPolicies.NO_CACHE }); const convertScalarProperties = (graphQLRelease) => @@ -52,24 +9,37 @@ const convertScalarProperties = (graphQLRelease) => 'name', 'tagName', 'tagPath', + 'description', 'descriptionHtml', 'releasedAt', 'upcomingRelease', ]); -const convertAssets = (graphQLRelease) => ({ - assets: { - count: graphQLRelease.assets.count, - sources: [...graphQLRelease.assets.sources.nodes], - links: graphQLRelease.assets.links.nodes.map((l) => ({ +const convertAssets = (graphQLRelease) => { + let sources = []; + if (graphQLRelease.assets.sources?.nodes) { + sources = [...graphQLRelease.assets.sources.nodes]; + } + + let links = []; + if (graphQLRelease.assets.links?.nodes) { + links = graphQLRelease.assets.links.nodes.map((l) => ({ ...l, linkType: l.linkType?.toLowerCase(), - })), - }, -}); + })); + } + + return { + assets: { + count: graphQLRelease.assets.count, + sources, + links, + }, + }; +}; const convertEvidences = (graphQLRelease) => ({ - evidences: graphQLRelease.evidences.nodes.map((e) => e), + evidences: (graphQLRelease.evidences?.nodes ?? []).map((e) => ({ ...e })), }); const convertLinks = (graphQLRelease) => ({ @@ -100,18 +70,19 @@ const convertMilestones = (graphQLRelease) => ({ ...m, webUrl: m.webPath, webPath: undefined, - issueStats: { - total: m.stats.totalIssuesCount, - closed: m.stats.closedIssuesCount, - }, + issueStats: m.stats + ? { + total: m.stats.totalIssuesCount, + closed: m.stats.closedIssuesCount, + } + : {}, stats: undefined, })), }); /** * Converts a single release object fetched from GraphQL - * into a release object that matches the shape of the REST API - * (the same shape that is returned by `apiJsonToRelease` above.) + * into a release object that matches the general structure of the REST API * * @param graphQLRelease The release object returned from a GraphQL query */ diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue index e5980f1e539..dabfb623f43 100644 --- a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue +++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue @@ -6,7 +6,7 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import ReportLink from '~/reports/components/report_link.vue'; -import { STATUS_SUCCESS } from '~/reports/constants'; +import { STATUS_SUCCESS, STATUS_NEUTRAL } from '~/reports/constants'; import { SEVERITY_CLASSES, SEVERITY_ICONS } from '../constants'; export default { @@ -21,7 +21,8 @@ export default { props: { status: { type: String, - required: true, + required: false, + default: STATUS_NEUTRAL, }, issue: { type: Object, diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue index d293165ef2f..3287ba691bf 100644 --- a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue +++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue @@ -3,7 +3,6 @@ import { mapState, mapActions, mapGetters } from 'vuex'; import { s__, sprintf } from '~/locale'; import { componentNames } from '~/reports/components/issue_body'; import ReportSection from '~/reports/components/report_section.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import createStore from './store'; export default { @@ -12,26 +11,12 @@ export default { components: { ReportSection, }, - mixins: [glFeatureFlagsMixin()], props: { - headPath: { - type: String, - required: true, - }, - headBlobPath: { - type: String, - required: true, - }, basePath: { type: String, required: false, default: null, }, - baseBlobPath: { - type: String, - required: false, - default: null, - }, codequalityReportsPath: { type: String, required: false, @@ -55,9 +40,6 @@ export default { created() { this.setPaths({ basePath: this.basePath, - headPath: this.headPath, - baseBlobPath: this.baseBlobPath, - headBlobPath: this.headBlobPath, reportsPath: this.codequalityReportsPath, helpPath: this.codequalityHelpPath, }); diff --git a/app/assets/javascripts/reports/codequality_report/store/actions.js b/app/assets/javascripts/reports/codequality_report/store/actions.js index ddd1747899f..e3238207af2 100644 --- a/app/assets/javascripts/reports/codequality_report/store/actions.js +++ b/app/assets/javascripts/reports/codequality_report/store/actions.js @@ -1,34 +1,23 @@ import axios from '~/lib/utils/axios_utils'; import * as types from './mutation_types'; -import { parseCodeclimateMetrics, doCodeClimateComparison } from './utils/codequality_comparison'; +import { parseCodeclimateMetrics } from './utils/codequality_parser'; export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths); -export const fetchReports = ({ state, dispatch, commit }, diffFeatureFlagEnabled) => { +export const fetchReports = ({ state, dispatch, commit }) => { commit(types.REQUEST_REPORTS); - if (diffFeatureFlagEnabled) { - return axios - .get(state.reportsPath) - .then(({ data }) => { - return dispatch('receiveReportsSuccess', { - newIssues: parseCodeclimateMetrics(data.new_errors, state.headBlobPath), - resolvedIssues: parseCodeclimateMetrics(data.resolved_errors, state.baseBlobPath), - }); - }) - .catch((error) => dispatch('receiveReportsError', error)); - } if (!state.basePath) { return dispatch('receiveReportsError'); } - return Promise.all([axios.get(state.headPath), axios.get(state.basePath)]) - .then((results) => - doCodeClimateComparison( - parseCodeclimateMetrics(results[0].data, state.headBlobPath), - parseCodeclimateMetrics(results[1].data, state.baseBlobPath), - ), - ) - .then((data) => dispatch('receiveReportsSuccess', data)) + return axios + .get(state.reportsPath) + .then(({ data }) => { + return dispatch('receiveReportsSuccess', { + newIssues: parseCodeclimateMetrics(data.new_errors, state.headBlobPath), + resolvedIssues: parseCodeclimateMetrics(data.resolved_errors, state.baseBlobPath), + }); + }) .catch((error) => dispatch('receiveReportsError', error)); }; diff --git a/app/assets/javascripts/reports/codequality_report/store/mutations.js b/app/assets/javascripts/reports/codequality_report/store/mutations.js index 095e6637966..8edeb6cc976 100644 --- a/app/assets/javascripts/reports/codequality_report/store/mutations.js +++ b/app/assets/javascripts/reports/codequality_report/store/mutations.js @@ -3,9 +3,6 @@ import * as types from './mutation_types'; export default { [types.SET_PATHS](state, paths) { state.basePath = paths.basePath; - state.headPath = paths.headPath; - state.baseBlobPath = paths.baseBlobPath; - state.headBlobPath = paths.headBlobPath; state.reportsPath = paths.reportsPath; state.helpPath = paths.helpPath; }, diff --git a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js index b252c8c9817..a794f5f0577 100644 --- a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js +++ b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js @@ -1,5 +1,3 @@ -import CodeQualityComparisonWorker from '../../workers/codequality_comparison_worker'; - export const parseCodeclimateMetrics = (issues = [], path = '') => { return issues.map((issue) => { const parsedIssue = { @@ -27,17 +25,3 @@ export const parseCodeclimateMetrics = (issues = [], path = '') => { return parsedIssue; }); }; - -export const doCodeClimateComparison = (headIssues, baseIssues) => { - // Do these comparisons in worker threads to avoid blocking the main thread - return new Promise((resolve, reject) => { - const worker = new CodeQualityComparisonWorker(); - worker.addEventListener('message', ({ data }) => - data.newIssues && data.resolvedIssues ? resolve(data) : reject(data), - ); - worker.postMessage({ - headIssues, - baseIssues, - }); - }); -}; diff --git a/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js b/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js deleted file mode 100644 index ae389d266f8..00000000000 --- a/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js +++ /dev/null @@ -1,28 +0,0 @@ -import { differenceBy } from 'lodash'; - -const KEY_TO_FILTER_BY = 'fingerprint'; - -// eslint-disable-next-line no-restricted-globals -self.addEventListener('message', (e) => { - const { data } = e; - - if (data === undefined) { - return null; - } - - const { headIssues, baseIssues } = data; - - if (!headIssues || !baseIssues) { - // eslint-disable-next-line no-restricted-globals - return self.postMessage({}); - } - - // eslint-disable-next-line no-restricted-globals - self.postMessage({ - newIssues: differenceBy(headIssues, baseIssues, KEY_TO_FILTER_BY), - resolvedIssues: differenceBy(baseIssues, headIssues, KEY_TO_FILTER_BY), - }); - - // eslint-disable-next-line no-restricted-globals - return self.close(); -}); diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 12b5cb9f207..7a490210f0b 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -190,10 +190,14 @@ export default { <status-icon :status="statusIconName" :size="24" class="align-self-center" /> <div class="media-body d-flex flex-align-self-center align-items-center"> <div data-testid="report-section-code-text" class="js-code-text code-text"> - <div> - {{ headerText }} + <div class="gl-display-flex gl-align-items-center"> + <p class="gl-line-height-normal gl-m-0">{{ headerText }}</p> <slot :name="slotName"></slot> - <popover v-if="hasPopover" :options="popoverOptions" class="gl-ml-2" /> + <popover + v-if="hasPopover" + :options="popoverOptions" + class="gl-ml-2 gl-display-inline-flex" + /> </div> <slot name="sub-heading"></slot> </div> diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 58b42fb7859..a9701c8f8aa 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -3,22 +3,21 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import BlobContent from '~/blob/components/blob_content.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; +import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants'; import createFlash from '~/flash'; import { __ } from '~/locale'; import blobInfoQuery from '../queries/blob_info.query.graphql'; -import projectPathQuery from '../queries/project_path.query.graphql'; +import BlobHeaderEdit from './blob_header_edit.vue'; export default { components: { BlobHeader, + BlobHeaderEdit, BlobContent, GlLoadingIcon, }, apollo: { - projectPath: { - query: projectPathQuery, - }, - blobInfo: { + project: { query: blobInfoQuery, variables() { return { @@ -26,6 +25,11 @@ export default { filePath: this.path, }; }, + result() { + this.switchViewer( + this.hasRichViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER, + ); + }, error() { createFlash({ message: __('An error occurred while loading the file. Please try again.') }); }, @@ -41,43 +45,70 @@ export default { type: String, required: true, }, + projectPath: { + type: String, + required: true, + }, }, data() { return { - projectPath: '', - blobInfo: { - name: '', - size: '', - rawBlob: '', - type: '', - fileType: '', - tooLarge: false, - path: '', - editBlobPath: '', - ideEditPath: '', - storedExternally: false, - rawPath: '', - externalStorageUrl: '', - replacePath: '', - deletePath: '', - canLock: false, - isLocked: false, - lockLink: '', - canModifyBlob: true, - forkPath: '', - simpleViewer: '', - richViewer: '', + activeViewerType: SIMPLE_BLOB_VIEWER, + project: { + repository: { + blobs: { + nodes: [ + { + name: '', + size: '', + rawTextBlob: '', + type: '', + fileType: '', + tooLarge: false, + path: '', + editBlobPath: '', + ideEditPath: '', + storedExternally: false, + rawPath: '', + externalStorageUrl: '', + replacePath: '', + deletePath: '', + canLock: false, + isLocked: false, + lockLink: '', + canModifyBlob: true, + forkPath: '', + simpleViewer: {}, + richViewer: null, + }, + ], + }, + }, }, }; }, computed: { isLoading() { - return this.$apollo.queries.blobInfo.loading; + return this.$apollo.queries.project.loading; }, - viewer() { - const { fileType, tooLarge, type } = this.blobInfo; + blobInfo() { + const nodes = this.project?.repository?.blobs?.nodes; - return { fileType, tooLarge, type }; + return nodes[0] || {}; + }, + viewer() { + const { richViewer, simpleViewer } = this.blobInfo; + return this.activeViewerType === RICH_BLOB_VIEWER ? richViewer : simpleViewer; + }, + hasRichViewer() { + return Boolean(this.blobInfo.richViewer); + }, + hasRenderError() { + return Boolean(this.viewer.renderError); + }, + }, + methods: { + switchViewer(newViewer) { + this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER; }, }, }; @@ -86,11 +117,21 @@ export default { <template> <div> <gl-loading-icon v-if="isLoading" /> - <div v-if="blobInfo && !isLoading"> - <blob-header :blob="blobInfo" /> + <div v-if="blobInfo && !isLoading" class="file-holder"> + <blob-header + :blob="blobInfo" + :hide-viewer-switcher="!hasRichViewer" + :active-viewer-type="viewer.type" + :has-render-error="hasRenderError" + @viewer-changed="switchViewer" + > + <template #actions> + <blob-header-edit :edit-path="blobInfo.editBlobPath" /> + </template> + </blob-header> <blob-content :blob="blobInfo" - :content="blobInfo.rawBlob" + :content="blobInfo.rawTextBlob" :is-raw-content="true" :active-viewer="viewer" :loading="false" diff --git a/app/assets/javascripts/repository/components/blob_header_edit.vue b/app/assets/javascripts/repository/components/blob_header_edit.vue new file mode 100644 index 00000000000..f3649895736 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_header_edit.vue @@ -0,0 +1,25 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + i18n: { + edit: __('Edit'), + }, + components: { + GlButton, + }, + props: { + editPath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <gl-button category="primary" variant="confirm" class="gl-mr-3" :href="editPath"> + {{ $options.i18n.edit }} + </gl-button> +</template> diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index d2ff01e7fc1..aa087d4c631 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -93,7 +93,7 @@ export default { text: PRIMARY_OPTIONS_TEXT, attributes: [ { - variant: 'success', + variant: 'confirm', loading: this.loading, disabled: !this.formCompleted || this.loading, }, diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 3a9a2adb417..501ae7e9f2f 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -4,6 +4,7 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import { escapeFileUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; +import PerformancePlugin from '~/performance/vue_performance_plugin'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; @@ -17,6 +18,10 @@ import createRouter from './router'; import { updateFormAction } from './utils/dom'; import { setTitle } from './utils/title'; +Vue.use(PerformancePlugin, { + components: ['SimpleViewer', 'BlobContent'], +}); + export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); const { dataset } = el; diff --git a/app/assets/javascripts/repository/pages/blob.vue b/app/assets/javascripts/repository/pages/blob.vue index 27af398be09..2645b294096 100644 --- a/app/assets/javascripts/repository/pages/blob.vue +++ b/app/assets/javascripts/repository/pages/blob.vue @@ -13,10 +13,14 @@ export default { type: String, required: true, }, + projectPath: { + type: String, + required: true, + }, }, }; </script> <template> - <blob-content-viewer :path="path" /> + <blob-content-viewer :path="path" :project-path="projectPath" /> </template> diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index e0bbf12f3eb..07c076af54b 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -2,28 +2,32 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) { project(fullPath: $projectPath) { id repository { - blobs(path: $filePath) { - name - size - rawBlob - type - fileType - tooLarge - path - editBlobPath - ideEditPath - storedExternally - rawPath - externalStorageUrl - replacePath - deletePath - canLock - isLocked - lockLink - canModifyBlob - forkPath - simpleViewer - richViewer + blobs(paths: [$filePath]) { + nodes { + webPath + name + size + rawSize + rawTextBlob + fileType + path + editBlobPath + storedExternally + rawPath + replacePath + simpleViewer { + fileType + tooLarge + type + renderError + } + richViewer { + fileType + tooLarge + type + renderError + } + } } } } diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index c7f7451fb55..6637d03a7a4 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -20,6 +20,7 @@ export default function createRouter(base, baseRef) { component: BlobPage, props: (route) => ({ path: route.params.path, + projectPath: base, }), }; diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue new file mode 100644 index 00000000000..dd4fff3a77a --- /dev/null +++ b/app/assets/javascripts/runner/components/runner_type_badge.vue @@ -0,0 +1,45 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; + +const badge = { + [INSTANCE_TYPE]: { + variant: 'success', + text: s__('Runners|shared'), + }, + [GROUP_TYPE]: { + variant: 'success', + text: s__('Runners|group'), + }, + [PROJECT_TYPE]: { + variant: 'info', + text: s__('Runners|specific'), + }, +}; + +export default { + components: { + GlBadge, + }, + props: { + type: { + type: String, + required: true, + }, + }, + computed: { + variant() { + return badge[this.type]?.variant; + }, + text() { + return badge[this.type]?.text; + }, + }, +}; +</script> +<template> + <gl-badge v-if="text" :variant="variant" v-bind="$attrs"> + {{ text }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js new file mode 100644 index 00000000000..de3a3fda47e --- /dev/null +++ b/app/assets/javascripts/runner/constants.js @@ -0,0 +1,11 @@ +import { s__ } from '~/locale'; + +export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); + +export const RUNNER_ENTITY_TYPE = 'Ci::Runner'; + +// CiRunnerType + +export const INSTANCE_TYPE = 'INSTANCE_TYPE'; +export const GROUP_TYPE = 'GROUP_TYPE'; +export const PROJECT_TYPE = 'PROJECT_TYPE'; diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql new file mode 100644 index 00000000000..d209313d4df --- /dev/null +++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql @@ -0,0 +1,6 @@ +query getRunner($id: CiRunnerID!) { + runner(id: $id) { + id + runnerType + } +} diff --git a/app/assets/javascripts/runner/runner_details/constants.js b/app/assets/javascripts/runner/runner_details/constants.js deleted file mode 100644 index bb57e85fa8a..00000000000 --- a/app/assets/javascripts/runner/runner_details/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -import { s__ } from '~/locale'; - -export const I18N_TITLE = s__('Runners|Runner #%{runner_id}'); diff --git a/app/assets/javascripts/runner/runner_details/index.js b/app/assets/javascripts/runner/runner_details/index.js index cbf70640ef7..05e6f86869d 100644 --- a/app/assets/javascripts/runner/runner_details/index.js +++ b/app/assets/javascripts/runner/runner_details/index.js @@ -1,7 +1,11 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import RunnerDetailsApp from './runner_details_app.vue'; -export const initRunnerDetail = (selector = '#js-runner-detail') => { +Vue.use(VueApollo); + +export const initRunnerDetail = (selector = '#js-runner-details') => { const el = document.querySelector(selector); if (!el) { @@ -10,8 +14,18 @@ export const initRunnerDetail = (selector = '#js-runner-detail') => { const { runnerId } = el.dataset; + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), + }); + return new Vue({ el, + apolloProvider, render(h) { return h(RunnerDetailsApp, { props: { diff --git a/app/assets/javascripts/runner/runner_details/runner_details_app.vue b/app/assets/javascripts/runner/runner_details/runner_details_app.vue index 1b1485bfe72..4736e547cb9 100644 --- a/app/assets/javascripts/runner/runner_details/runner_details_app.vue +++ b/app/assets/javascripts/runner/runner_details/runner_details_app.vue @@ -1,9 +1,15 @@ <script> -import { I18N_TITLE } from './constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import RunnerTypeBadge from '../components/runner_type_badge.vue'; +import { I18N_DETAILS_TITLE, RUNNER_ENTITY_TYPE } from '../constants'; +import getRunnerQuery from '../graphql/get_runner.query.graphql'; export default { + components: { + RunnerTypeBadge, + }, i18n: { - I18N_TITLE, + I18N_DETAILS_TITLE, }, props: { runnerId: { @@ -11,10 +17,27 @@ export default { required: true, }, }, + data() { + return { + runner: {}, + }; + }, + apollo: { + runner: { + query: getRunnerQuery, + variables() { + return { + id: convertToGraphQLId(RUNNER_ENTITY_TYPE, this.runnerId), + }; + }, + }, + }, }; </script> <template> <h2 class="page-title"> - {{ sprintf($options.i18n.I18N_TITLE, { runner_id: runnerId }) }} + {{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }} + + <runner-type-badge v-if="runner.runnerType" :type="runner.runnerType" /> </h2> </template> diff --git a/app/assets/javascripts/security_configuration/components/configuration_table.vue b/app/assets/javascripts/security_configuration/components/configuration_table.vue index 4a3f988296c..2110af1522b 100644 --- a/app/assets/javascripts/security_configuration/components/configuration_table.vue +++ b/app/assets/javascripts/security_configuration/components/configuration_table.vue @@ -1,6 +1,7 @@ <script> import { GlLink, GlTable, GlAlert } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; +import ManageViaMR from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; import { REPORT_TYPE_SAST, REPORT_TYPE_DAST, @@ -11,8 +12,8 @@ import { REPORT_TYPE_API_FUZZING, REPORT_TYPE_LICENSE_COMPLIANCE, } from '~/vue_shared/security_reports/constants'; -import ManageSast from './manage_sast.vue'; -import { scanners } from './scanners_constants'; + +import { scanners } from './constants'; import Upgrade from './upgrade.vue'; const borderClasses = 'gl-border-b-1! gl-border-b-solid! gl-border-gray-100!'; @@ -40,7 +41,7 @@ export default { }, getComponentForItem(item) { const COMPONENTS = { - [REPORT_TYPE_SAST]: ManageSast, + [REPORT_TYPE_SAST]: ManageViaMR, [REPORT_TYPE_DAST]: Upgrade, [REPORT_TYPE_DAST_PROFILES]: Upgrade, [REPORT_TYPE_DEPENDENCY_SCANNING]: Upgrade, @@ -49,7 +50,6 @@ export default { [REPORT_TYPE_API_FUZZING]: Upgrade, [REPORT_TYPE_LICENSE_COMPLIANCE]: Upgrade, }; - return COMPONENTS[item.type]; }, }, @@ -95,7 +95,12 @@ export default { </template> <template #cell(manage)="{ item }"> - <component :is="getComponentForItem(item)" :data-testid="item.type" @error="onError" /> + <component + :is="getComponentForItem(item)" + :feature="item" + :data-testid="item.type" + @error="onError" + /> </template> </gl-table> </div> diff --git a/app/assets/javascripts/security_configuration/components/scanners_constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 9846df0b4bf..3cdcac4c0b4 100644 --- a/app/assets/javascripts/security_configuration/components/scanners_constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -1,6 +1,7 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { __, s__ } from '~/locale'; +import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql'; import { REPORT_TYPE_SAST, REPORT_TYPE_DAST, @@ -134,3 +135,18 @@ export const scanners = [ type: REPORT_TYPE_LICENSE_COMPLIANCE, }, ]; + +export const featureToMutationMap = { + [REPORT_TYPE_SAST]: { + mutationId: 'configureSast', + getMutationPayload: (projectPath) => ({ + mutation: configureSastMutation, + variables: { + input: { + projectPath, + configuration: { global: [], pipeline: [], analyzers: [] }, + }, + }, + }), + }, +}; diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue new file mode 100644 index 00000000000..518a6ede3de --- /dev/null +++ b/app/assets/javascripts/security_configuration/components/feature_card.vue @@ -0,0 +1,150 @@ +<script> +import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; +import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; + +export default { + components: { + GlButton, + GlCard, + GlIcon, + GlLink, + ManageViaMr, + }, + props: { + feature: { + type: Object, + required: true, + }, + }, + computed: { + available() { + return this.feature.available; + }, + enabled() { + return this.available && this.feature.configured; + }, + hasStatus() { + return !this.available || typeof this.feature.configured === 'boolean'; + }, + shortName() { + return this.feature.shortName ?? this.feature.name; + }, + configurationButton() { + const button = this.enabled + ? { + text: this.$options.i18n.configureFeature, + category: 'secondary', + } + : { + text: this.$options.i18n.enableFeature, + category: 'primary', + }; + + button.text = sprintf(button.text, { feature: this.shortName }); + + return button; + }, + showManageViaMr() { + const { available, configured, canEnableByMergeRequest } = this.feature; + return canEnableByMergeRequest && available && !configured; + }, + cardClasses() { + return { 'gl-bg-gray-10': !this.available }; + }, + statusClasses() { + const { enabled } = this; + + return { + 'gl-ml-auto': true, + 'gl-flex-shrink-0': true, + 'gl-text-gray-500': !enabled, + 'gl-text-green-500': enabled, + }; + }, + hasSecondary() { + const { name, description, configurationText } = this.feature.secondary ?? {}; + return Boolean(name && description && configurationText); + }, + }, + i18n: { + enabled: s__('SecurityConfiguration|Enabled'), + notEnabled: s__('SecurityConfiguration|Not enabled'), + availableWith: s__('SecurityConfiguration|Available with Ultimate'), + configurationGuide: s__('SecurityConfiguration|Configuration guide'), + configureFeature: s__('SecurityConfiguration|Configure %{feature}'), + enableFeature: s__('SecurityConfiguration|Enable %{feature}'), + learnMore: __('Learn more'), + }, +}; +</script> + +<template> + <gl-card :class="cardClasses"> + <div class="gl-display-flex gl-align-items-baseline"> + <h3 class="gl-font-lg gl-m-0 gl-mr-3">{{ feature.name }}</h3> + + <div :class="statusClasses" data-testid="feature-status"> + <template v-if="hasStatus"> + <template v-if="enabled"> + <gl-icon name="check-circle-filled" /> + <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span> + </template> + + <template v-else-if="available"> + {{ $options.i18n.notEnabled }} + </template> + + <template v-else> + {{ $options.i18n.availableWith }} + </template> + </template> + </div> + </div> + + <p class="gl-mb-0 gl-mt-5"> + {{ feature.description }} + <gl-link :href="feature.helpPath">{{ $options.i18n.learnMore }}</gl-link> + </p> + + <template v-if="available"> + <gl-button + v-if="feature.configurationPath" + :href="feature.configurationPath" + variant="confirm" + :category="configurationButton.category" + class="gl-mt-5" + > + {{ configurationButton.text }} + </gl-button> + + <manage-via-mr + v-else-if="showManageViaMr" + :feature="feature" + variant="confirm" + category="primary" + class="gl-mt-5" + /> + + <gl-button v-else icon="external-link" :href="feature.configurationHelpPath" class="gl-mt-5"> + {{ $options.i18n.configurationGuide }} + </gl-button> + </template> + + <div v-if="hasSecondary" data-testid="secondary-feature"> + <h4 class="gl-font-base gl-m-0 gl-mt-6">{{ feature.secondary.name }}</h4> + + <p class="gl-mb-0 gl-mt-5">{{ feature.secondary.description }}</p> + + <gl-button + v-if="available && feature.secondary.configurationPath" + :href="feature.secondary.configurationPath" + variant="confirm" + category="secondary" + class="gl-mt-5" + > + {{ feature.secondary.configurationText }} + </gl-button> + </div> + </gl-card> +</template> diff --git a/app/assets/javascripts/security_configuration/components/manage_sast.vue b/app/assets/javascripts/security_configuration/components/manage_sast.vue deleted file mode 100644 index 8a8827b41cd..00000000000 --- a/app/assets/javascripts/security_configuration/components/manage_sast.vue +++ /dev/null @@ -1,59 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import { redirectTo } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; -import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql'; - -export default { - components: { - GlButton, - }, - inject: { - projectPath: { - from: 'projectPath', - default: '', - }, - }, - data() { - return { - isLoading: false, - }; - }, - methods: { - async mutate() { - this.isLoading = true; - try { - const { data } = await this.$apollo.mutate({ - mutation: configureSastMutation, - variables: { - input: { - projectPath: this.projectPath, - configuration: { global: [], pipeline: [], analyzers: [] }, - }, - }, - }); - const { errors, successPath } = data.configureSast; - - if (errors.length > 0) { - throw new Error(errors[0]); - } - - if (!successPath) { - throw new Error(s__('SecurityConfiguration|SAST merge request creation mutation failed')); - } - - redirectTo(successPath); - } catch (e) { - this.$emit('error', e.message); - this.isLoading = false; - } - }, - }, -}; -</script> - -<template> - <gl-button :loading="isLoading" variant="success" category="secondary" @click="mutate">{{ - s__('SecurityConfiguration|Configure via merge request') - }}</gl-button> -</template> diff --git a/app/assets/javascripts/security_configuration/components/upgrade.vue b/app/assets/javascripts/security_configuration/components/upgrade.vue index 518eb57731d..2541c29224a 100644 --- a/app/assets/javascripts/security_configuration/components/upgrade.vue +++ b/app/assets/javascripts/security_configuration/components/upgrade.vue @@ -1,6 +1,6 @@ <script> import { GlLink, GlSprintf } from '@gitlab/ui'; -import { UPGRADE_CTA } from './scanners_constants'; +import { UPGRADE_CTA } from './constants'; export default { components: { diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index bff90254c04..c754af5c7de 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -67,11 +67,6 @@ export default { required: false, default: '', }, - canSetUserAvailability: { - type: Boolean, - required: false, - default: false, - }, currentClearStatusAfter: { type: String, required: false, @@ -292,7 +287,7 @@ export default { </button> </span> </div> - <div v-if="canSetUserAvailability" class="form-group"> + <div class="form-group"> <div class="gl-display-flex"> <gl-form-checkbox v-model="availability" diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js index 467cd321fb8..3ca9288b156 100644 --- a/app/assets/javascripts/shared/milestones/form.js +++ b/app/assets/javascripts/shared/milestones/form.js @@ -1,11 +1,12 @@ import $ from 'jquery'; -import DueDateSelectors from '../../due_date_select'; +import initDatePicker from '~/behaviors/date_picker'; import GLForm from '../../gl_form'; import ZenMode from '../../zen_mode'; export default (initGFM = true) => { new ZenMode(); // eslint-disable-line no-new - new DueDateSelectors(); // eslint-disable-line no-new + initDatePicker(); + // eslint-disable-next-line no-new new GLForm($('.milestone-form'), { emojis: true, diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index f98798582c1..e7ef731eed8 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -1,6 +1,7 @@ <script> -import actionCable from '~/actioncable_consumer'; -import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import produce from 'immer'; +import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { IssuableType } from '~/issue_show/constants'; import { assigneesQueries } from '~/sidebar/constants'; export default { @@ -12,60 +13,62 @@ export default { required: false, default: null, }, - issuableIid: { + issuableType: { type: String, required: true, }, - projectPath: { - type: String, + issuableId: { + type: Number, required: true, }, - issuableType: { - type: String, + queryVariables: { + type: Object, required: true, }, }, + computed: { + issuableClass() { + return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType); + }, + }, apollo: { - workspace: { + issuable: { query() { return assigneesQueries[this.issuableType].query; }, variables() { - return { - iid: this.issuableIid, - fullPath: this.projectPath, - }; + return this.queryVariables; + }, + update(data) { + return data.workspace?.issuable; }, - result(data) { - if (this.mediator) { - this.handleFetchResult(data); - } + subscribeToMore: { + document() { + return assigneesQueries[this.issuableType].subscription; + }, + variables() { + return { + issuableId: convertToGraphQLId(this.issuableClass, this.issuableId), + }; + }, + updateQuery(prev, { subscriptionData }) { + if (prev && subscriptionData?.data?.issuableAssigneesUpdated) { + const data = produce(prev, (draftData) => { + draftData.workspace.issuable.assignees.nodes = + subscriptionData.data.issuableAssigneesUpdated.assignees.nodes; + }); + if (this.mediator) { + this.handleFetchResult(data); + } + return data; + } + return prev; + }, }, }, }, - mounted() { - this.initActionCablePolling(); - }, - beforeDestroy() { - this.$options.subscription.unsubscribe(); - }, methods: { - received(data) { - if (data.event === 'updated') { - this.$apollo.queries.workspace.refetch(); - } - }, - initActionCablePolling() { - this.$options.subscription = actionCable.subscriptions.create( - { - channel: 'IssuesChannel', - project_path: this.projectPath, - iid: this.issuableIid, - }, - { received: this.received }, - ); - }, - handleFetchResult({ data }) { + handleFetchResult(data) { const { nodes } = data.workspace.issuable.assignees; const assignees = nodes.map((n) => ({ diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index e93aced12f3..80caebad39d 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -60,7 +60,7 @@ export default { v-else :users="users" :issuable-type="issuableType" - class="gl-mt-2 hide-collapsed" + class="gl-text-gray-800 gl-mt-2 hide-collapsed" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index e15ea595190..ca95599742a 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -44,6 +44,10 @@ export default { type: String, required: true, }, + issuableId: { + type: Number, + required: true, + }, assigneeAvailabilityStatus: { type: Object, required: false, @@ -61,6 +65,12 @@ export default { // Note: Realtime is only available on issues right now, future support for MR wil be built later. return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue'; }, + queryVariables() { + return { + iid: this.issuableIid, + fullPath: this.projectPath, + }; + }, relativeUrlRoot() { return gon.relative_url_root ?? ''; }, @@ -121,9 +131,9 @@ export default { <div> <assignees-realtime v-if="shouldEnableRealtime" - :issuable-iid="issuableIid" - :project-path="projectPath" :issuable-type="issuableType" + :issuable-id="issuableId" + :query-variables="queryVariables" :mediator="mediator" /> <assignee-title diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 78cac989850..932be7addc0 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -1,19 +1,17 @@ <script> -import { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import Vue from 'vue'; import createFlash from '~/flash'; -import searchUsers from '~/graphql_shared/queries/users_search.query.graphql'; import { IssuableType } from '~/issue_show/constants'; import { __, n__ } from '~/locale'; import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; -import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; +import { assigneesQueries } from '~/sidebar/constants'; +import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import SidebarInviteMembers from './sidebar_invite_members.vue'; -import SidebarParticipant from './sidebar_participant.vue'; export const assigneesWidget = Vue.observable({ updateAssignees: null, @@ -33,23 +31,16 @@ export default { components: { SidebarEditableItem, IssuableAssignees, - MultiSelectDropdown, GlDropdownItem, - GlDropdownDivider, - GlSearchBoxByType, - GlLoadingIcon, SidebarInviteMembers, - SidebarParticipant, SidebarAssigneesRealtime, + UserSelect, }, mixins: [glFeatureFlagsMixin()], inject: { directlyInviteMembers: { default: false, }, - indirectlyInviteMembers: { - default: false, - }, }, props: { iid: { @@ -73,20 +64,21 @@ export default { return [IssuableType.Issue, IssuableType.MergeRequest].includes(value); }, }, - multipleAssignees: { - type: Boolean, + issuableId: { + type: Number, required: false, - default: true, + default: null, + }, + allowMultipleAssignees: { + type: Boolean, + required: true, }, }, data() { return { - search: '', issuable: {}, - searchUsers: [], selected: [], isSettingAssignees: false, - isSearching: false, isDirty: false, }; }, @@ -104,51 +96,13 @@ export default { result({ data }) { const issuable = data.workspace?.issuable; if (issuable) { - this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes)); + this.selected = cloneDeep(issuable.assignees.nodes); } }, error() { createFlash({ message: __('An error occurred while fetching participants.') }); }, }, - searchUsers: { - query: searchUsers, - variables() { - return { - fullPath: this.fullPath, - search: this.search, - }; - }, - update(data) { - const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || []; - const filteredParticipants = this.participants.filter( - (user) => - user.name.toLowerCase().includes(this.search.toLowerCase()) || - user.username.toLowerCase().includes(this.search.toLowerCase()), - ); - const mergedSearchResults = searchResults.reduce((acc, current) => { - // Some users are duplicated in the query result: - // https://gitlab.com/gitlab-org/gitlab/-/issues/327822 - if (!acc.some((user) => current.username === user.username)) { - acc.push(current); - } - return acc; - }, filteredParticipants); - - return mergedSearchResults; - }, - debounce: ASSIGNEES_DEBOUNCE_DELAY, - skip() { - return this.isSearchEmpty; - }, - error() { - createFlash({ message: __('An error occurred while searching users.') }); - this.isSearching = false; - }, - result() { - this.isSearching = false; - }, - }, }, computed: { shouldEnableRealtime() { @@ -167,13 +121,6 @@ export default { : this.issuable?.assignees?.nodes; return currentAssignees || []; }, - participants() { - const users = - this.isSearchEmpty || this.isSearching - ? this.issuable?.participants?.nodes - : this.searchUsers; - return this.moveCurrentUserToStart(users); - }, assigneeText() { const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected; if (!items) { @@ -181,28 +128,8 @@ export default { } return n__('Assignee', '%d Assignees', items.length); }, - selectedFiltered() { - if (this.isSearchEmpty || this.isSearching) { - return this.selected; - } - - const foundUsernames = this.searchUsers.map(({ username }) => username); - return this.selected.filter(({ username }) => foundUsernames.includes(username)); - }, - unselectedFiltered() { - return ( - this.participants?.filter(({ username }) => !this.selectedUserNames.includes(username)) || - [] - ); - }, - selectedIsEmpty() { - return this.selectedFiltered.length === 0; - }, - selectedUserNames() { - return this.selected.map(({ username }) => username); - }, - isSearchEmpty() { - return this.search === ''; + isAssigneesLoading() { + return !this.initialAssignees && this.$apollo.queries.issuable.loading; }, currentUser() { return { @@ -211,35 +138,9 @@ export default { avatarUrl: gon?.current_user_avatar_url, }; }, - isAssigneesLoading() { - return !this.initialAssignees && this.$apollo.queries.issuable.loading; - }, - isCurrentUserInParticipants() { - const isCurrentUser = (user) => user.username === this.currentUser.username; - return this.selected.some(isCurrentUser) || this.participants.some(isCurrentUser); - }, - noUsersFound() { - return !this.isSearchEmpty && this.searchUsers.length === 0; - }, signedIn() { return this.currentUser.username !== undefined; }, - showCurrentUser() { - return ( - this.signedIn && - !this.isCurrentUserInParticipants && - (this.isSearchEmpty || this.isSearching) - ); - }, - }, - watch: { - // We need to add this watcher to track the moment when user is alredy typing - // but query is still not started due to debounce - search(newVal) { - if (newVal) { - this.isSearching = true; - } - }, }, created() { assigneesWidget.updateAssignees = this.updateAssignees; @@ -269,59 +170,15 @@ export default { this.isSettingAssignees = false; }); }, - selectAssignee(name) { - this.isDirty = true; - - if (!this.multipleAssignees) { - this.selected = name ? [name] : []; - this.collapseWidget(); - return; - } - if (name === undefined) { - this.clearSelected(); - return; - } - this.selected = this.selected.concat(name); - }, - unselect(name) { - this.selected = this.selected.filter((user) => user.username !== name); - this.isDirty = true; - - if (!this.multipleAssignees) { - this.collapseWidget(); - } - }, assignSelf() { - this.updateAssignees(this.currentUser.username); - }, - clearSelected() { - this.selected = []; + this.updateAssignees([this.currentUser.username]); }, saveAssignees() { - this.isDirty = false; - this.updateAssignees(this.selectedUserNames); - this.$el.dispatchEvent(hideDropdownEvent); - }, - isChecked(id) { - return this.selectedUserNames.includes(id); - }, - async focusSearch() { - await this.$nextTick(); - this.$refs.search.focusInput(); - }, - moveCurrentUserToStart(users) { - if (!users) { - return []; - } - const usersCopy = [...users]; - const currentUser = usersCopy.find((user) => user.username === this.currentUser.username); - - if (currentUser) { - const index = usersCopy.indexOf(currentUser); - usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]); + if (this.isDirty) { + this.isDirty = false; + this.updateAssignees(this.selected.map(({ username }) => username)); } - - return usersCopy; + this.$el.dispatchEvent(hideDropdownEvent); }, collapseWidget() { this.$refs.toggle.collapse(); @@ -329,8 +186,17 @@ export default { expandWidget() { this.$refs.toggle.expand(); }, - showDivider(list) { - return list.length > 0 && this.isSearchEmpty; + focusSearch() { + this.$refs.userSelect.focusSearch(); + }, + showError() { + createFlash({ message: __('An error occurred while fetching participants.') }); + }, + setDirtyState() { + this.isDirty = true; + if (!this.allowMultipleAssignees) { + this.collapseWidget(); + } }, }, }; @@ -340,9 +206,9 @@ export default { <div data-testid="assignees-widget"> <sidebar-assignees-realtime v-if="shouldEnableRealtime" - :project-path="fullPath" - :issuable-iid="iid" :issuable-type="issuableType" + :issuable-id="issuableId" + :query-variables="queryVariables" /> <sidebar-editable-item ref="toggle" @@ -363,86 +229,27 @@ export default { @expand-widget="expandWidget" /> </template> - <template #default> - <multi-select-dropdown - class="gl-w-full dropdown-menu-user" + <user-select + ref="userSelect" + v-model="selected" :text="$options.i18n.assignees" :header-text="$options.i18n.assignTo" + :iid="iid" + :full-path="fullPath" + :allow-multiple-assignees="allowMultipleAssignees" + :current-user="currentUser" + :issuable-type="issuableType" + class="gl-w-full dropdown-menu-user" @toggle="collapseWidget" + @error="showError" + @input="setDirtyState" > - <template #search> - <gl-search-box-by-type - ref="search" - v-model.trim="search" - class="js-dropdown-input-field" - /> - </template> - <template #items> - <gl-loading-icon - v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading" - data-testid="loading-participants" - size="lg" - /> - <template v-else> - <template v-if="isSearchEmpty || isSearching"> - <gl-dropdown-item - :is-checked="selectedIsEmpty" - :is-check-centered="true" - data-testid="unassign" - @click="selectAssignee()" - > - <span - :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" - class="gl-font-weight-bold" - >{{ $options.i18n.unassigned }}</span - ></gl-dropdown-item - > - </template> - <gl-dropdown-divider v-if="showDivider(selectedFiltered)" /> - <gl-dropdown-item - v-for="item in selectedFiltered" - :key="item.id" - :is-checked="isChecked(item.username)" - :is-check-centered="true" - data-testid="selected-participant" - @click.stop="unselect(item.username)" - > - <sidebar-participant :user="item" /> - </gl-dropdown-item> - <template v-if="showCurrentUser"> - <gl-dropdown-divider /> - <gl-dropdown-item - data-testid="current-user" - @click.stop="selectAssignee(currentUser)" - > - <sidebar-participant :user="currentUser" class="gl-pl-6!" /> - </gl-dropdown-item> - </template> - <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> - <gl-dropdown-item - v-for="unselectedUser in unselectedFiltered" - :key="unselectedUser.id" - data-testid="unselected-participant" - @click="selectAssignee(unselectedUser)" - > - <sidebar-participant :user="unselectedUser" class="gl-pl-6!" /> - </gl-dropdown-item> - <gl-dropdown-item - v-if="noUsersFound && !isSearching" - data-testid="empty-results" - class="gl-pl-6!" - > - {{ __('No matching results') }} - </gl-dropdown-item> - </template> - </template> <template #footer> - <gl-dropdown-item> - <sidebar-invite-members v-if="directlyInviteMembers || indirectlyInviteMembers" /> - </gl-dropdown-item> - </template> - </multi-select-dropdown> + <gl-dropdown-item v-if="directlyInviteMembers"> + <sidebar-invite-members /> + </gl-dropdown-item> </template + ></user-select> </template> </sidebar-editable-item> </div> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue index 9952c6db582..5c32d03e0d4 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue @@ -1,51 +1,23 @@ <script> -import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue'; -import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import { __ } from '~/locale'; export default { displayText: __('Invite members'), dataTrackLabel: 'edit_assignee', + dataTrackEvent: 'click_invite_members', components: { - InviteMemberTrigger, - InviteMemberModal, InviteMembersTrigger, }, - inject: { - projectMembersPath: { - default: '', - }, - directlyInviteMembers: { - default: false, - }, - }, - computed: { - trackEvent() { - return this.directlyInviteMembers ? 'click_invite_members' : 'click_invite_members_version_b'; - }, - }, }; </script> <template> - <div> - <invite-members-trigger - v-if="directlyInviteMembers" - trigger-element="anchor" - :display-text="$options.displayText" - :event="trackEvent" - :label="$options.dataTrackLabel" - classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" - /> - <template v-else> - <invite-member-trigger - :display-text="$options.displayText" - :event="trackEvent" - :label="$options.dataTrackLabel" - class="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" - /> - <invite-member-modal :members-path="projectMembersPath" /> - </template> - </div> + <invite-members-trigger + trigger-element="anchor" + :display-text="$options.displayText" + :event="$options.dataTrackEvent" + :label="$options.dataTrackLabel" + classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" + /> </template> diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue new file mode 100644 index 00000000000..6a68e914b84 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -0,0 +1,296 @@ +<script> +import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; +import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { dateFields, dateTypes, dueDateQueries, startDateQueries } from '~/sidebar/constants'; +import SidebarFormattedDate from './sidebar_formatted_date.vue'; +import SidebarInheritDate from './sidebar_inherit_date.vue'; + +const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { + bubbles: true, +}); + +export default { + tracking: { + event: 'click_edit_button', + label: 'right_sidebar', + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlIcon, + GlDatepicker, + GlLink, + GlPopover, + SidebarEditableItem, + SidebarFormattedDate, + SidebarInheritDate, + }, + inject: ['canUpdate'], + props: { + iid: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + dateType: { + type: String, + required: false, + default: dateTypes.due, + }, + issuableType: { + required: true, + type: String, + }, + canInherit: { + required: false, + type: Boolean, + default: false, + }, + }, + data() { + return { + issuable: {}, + loading: false, + tracking: { + ...this.$options.tracking, + property: this.dateType === dateTypes.start ? 'startDate' : 'dueDate', + }, + }; + }, + apollo: { + issuable: { + query() { + return this.dateQueries[this.issuableType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.iid), + }; + }, + update(data) { + return data.workspace?.issuable || {}; + }, + result({ data }) { + this.$emit(`${this.dateType}Updated`, data.workspace?.issuable?.[this.dateType]); + }, + error() { + createFlash({ + message: sprintf( + __('Something went wrong while setting %{issuableType} %{dateType} date.'), + { + issuableType: this.issuableType, + dateType: this.dateType === dateTypes.start ? 'start' : 'due', + }, + ), + }); + }, + }, + }, + computed: { + dateQueries() { + return this.dateType === dateTypes.start ? startDateQueries : dueDateQueries; + }, + dateLabel() { + return this.dateType === dateTypes.start + ? this.$options.i18n.startDate + : this.$options.i18n.dueDate; + }, + removeDateLabel() { + return this.dateType === dateTypes.start + ? this.$options.i18n.removeStartDate + : this.$options.i18n.removeDueDate; + }, + dateValue() { + return this.issuable?.[this.dateType] || null; + }, + isLoading() { + return this.$apollo.queries.issuable.loading || this.loading; + }, + hasDate() { + return this.dateValue !== null; + }, + parsedDate() { + if (!this.hasDate) { + return null; + } + + return parsePikadayDate(this.dateValue); + }, + formattedDate() { + if (!this.hasDate) { + return this.$options.i18n.noDate; + } + + return dateInWords(this.parsedDate, true); + }, + workspacePath() { + return this.issuableType === IssuableType.Issue + ? { + projectPath: this.fullPath, + } + : { + groupPath: this.fullPath, + }; + }, + dataTestId() { + return this.dateType === dateTypes.start ? 'start-date' : 'due-date'; + }, + }, + methods: { + closeForm() { + this.$refs.editable.collapse(); + this.$el.dispatchEvent(hideDropdownEvent); + this.$emit('closeForm'); + }, + openDatePicker() { + this.$refs.datePicker.calendar.show(); + }, + setFixedDate(isFixed) { + const date = this.issuable[dateFields[this.dateType].dateFixed]; + this.setDate(date, isFixed); + }, + setDate(date, isFixed = true) { + const formattedDate = date ? formatDate(date, 'yyyy-mm-dd') : null; + this.loading = true; + this.$refs.editable.collapse(); + this.$apollo + .mutate({ + mutation: this.dateQueries[this.issuableType].mutation, + variables: { + input: { + ...this.workspacePath, + iid: this.iid, + ...(this.canInherit + ? { + [dateFields[this.dateType].dateFixed]: isFixed ? formattedDate : undefined, + [dateFields[this.dateType].isDateFixed]: isFixed, + } + : { + [this.dateType]: formattedDate, + }), + }, + }, + }) + .then( + ({ + data: { + issuableSetDate: { errors }, + }, + }) => { + if (errors.length) { + createFlash({ + message: errors[0], + }); + } else { + this.$emit('closeForm'); + } + }, + ) + .catch(() => { + createFlash({ + message: sprintf( + __('Something went wrong while setting %{issuableType} %{dateType} date.'), + { + issuableType: this.issuableType, + dateType: this.dateType === dateTypes.start ? 'start' : 'due', + }, + ), + }); + }) + .finally(() => { + this.loading = false; + }); + }, + }, + i18n: { + dueDate: __('Due date'), + startDate: __('Start date'), + noDate: __('None'), + removeDueDate: __('remove due date'), + removeStartDate: __('remove start date'), + dateHelpValidMessage: __( + 'These dates affect how your epics appear in the roadmap. Set a fixed date or one inherited from the milestones assigned to issues in this epic.', + ), + help: __('Help'), + learnMore: __('Learn more'), + }, + dateHelpUrl: '/help/user/group/epics/index.md#start-date-and-due-date', +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="dateLabel" + :tracking="tracking" + :loading="isLoading" + class="block" + :data-testid="dataTestId" + @open="openDatePicker" + > + <template v-if="canInherit" #title-extra> + <gl-icon + ref="epicDatePopover" + name="question-o" + class="gl-ml-3 gl-cursor-pointer gl-text-blue-600 hide-collapsed" + tabindex="0" + :aria-label="$options.i18n.help" + data-testid="inherit-date-popover" + /> + <gl-popover + :target="() => $refs.epicDatePopover.$el" + triggers="focus" + placement="left" + boundary="viewport" + > + <p>{{ $options.i18n.dateHelpValidMessage }}</p> + <gl-link :href="$options.dateHelpUrl" target="_blank">{{ + $options.i18n.learnMore + }}</gl-link> + </gl-popover> + </template> + <template #collapsed> + <div v-gl-tooltip :title="dateLabel" class="sidebar-collapsed-icon"> + <gl-icon :size="16" name="calendar" /> + <span class="collapse-truncated-title">{{ formattedDate }}</span> + </div> + <sidebar-inherit-date + v-if="canInherit" + :issuable="issuable" + :is-loading="isLoading" + :date-type="dateType" + @reset-date="setDate(null)" + @set-date="setFixedDate" + /> + <sidebar-formatted-date + v-else + :has-date="hasDate" + :formatted-date="formattedDate" + :reset-text="removeDateLabel" + :is-loading="isLoading" + @reset-date="setDate(null)" + /> + </template> + <template #default> + <gl-datepicker + v-if="!isLoading" + ref="datePicker" + class="gl-relative" + :default-date="parsedDate" + show-clear-button + autocomplete="off" + @input="setDate" + @clear="setDate(null)" + /> + </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue b/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue new file mode 100644 index 00000000000..87cf1c29fb0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue @@ -0,0 +1,56 @@ +<script> +import { GlButton } from '@gitlab/ui'; + +export default { + components: { + GlButton, + }, + inject: ['canUpdate'], + props: { + formattedDate: { + required: true, + type: String, + }, + hasDate: { + required: true, + type: Boolean, + }, + resetText: { + required: true, + type: String, + }, + isLoading: { + required: true, + type: Boolean, + }, + canDelete: { + required: false, + type: Boolean, + default: true, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center hide-collapsed"> + <span + :class="hasDate ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'" + data-testid="sidebar-date-value" + > + {{ formattedDate }} + </span> + <div v-if="hasDate && canUpdate && canDelete" class="gl-display-flex"> + <span class="gl-px-2">-</span> + <gl-button + variant="link" + class="gl-text-gray-500!" + data-testid="reset-button" + :disabled="isLoading" + @click="$emit('reset-date', $event)" + > + {{ resetText }} + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue new file mode 100644 index 00000000000..b6bfacb2e47 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue @@ -0,0 +1,110 @@ +<script> +import { GlFormRadio } from '@gitlab/ui'; +import { dateInWords, parsePikadayDate } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; +import { dateFields } from '../../constants'; +import SidebarFormattedDate from './sidebar_formatted_date.vue'; + +export default { + components: { + GlFormRadio, + SidebarFormattedDate, + }, + inject: ['canUpdate'], + props: { + issuable: { + required: true, + type: Object, + }, + isLoading: { + required: true, + type: Boolean, + }, + dateType: { + type: String, + required: true, + }, + }, + computed: { + dateIsFixed: { + get() { + return this.issuable?.[dateFields[this.dateType].isDateFixed] || false; + }, + set(fixed) { + this.$emit('set-date', fixed); + }, + }, + hasFixedDate() { + return this.issuable[dateFields[this.dateType].dateFixed] !== null; + }, + formattedFixedDate() { + const dateFixed = this.issuable[dateFields[this.dateType].dateFixed]; + if (!dateFixed) { + return this.$options.i18n.noDate; + } + + return dateInWords(parsePikadayDate(dateFixed), true); + }, + formattedInheritedDate() { + const dateFromMilestones = this.issuable[dateFields[this.dateType].dateFromMilestones]; + if (!dateFromMilestones) { + return this.$options.i18n.noDate; + } + + return dateInWords(parsePikadayDate(dateFromMilestones), true); + }, + }, + i18n: { + fixed: __('Fixed:'), + inherited: __('Inherited:'), + remove: __('remove'), + noDate: __('None'), + }, +}; +</script> + +<template> + <div class="hide-collapsed gl-mt-3"> + <div class="gl-display-flex gl-align-items-baseline" data-testid="sidebar-fixed-date"> + <gl-form-radio + v-model="dateIsFixed" + :value="true" + :disabled="!canUpdate || isLoading" + class="gl-pr-2" + > + <span :class="dateIsFixed ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'"> + {{ $options.i18n.fixed }} + </span> + </gl-form-radio> + <sidebar-formatted-date + :has-date="dateIsFixed" + :formatted-date="formattedFixedDate" + :reset-text="$options.i18n.remove" + :is-loading="isLoading" + :can-delete="dateIsFixed && hasFixedDate" + class="gl-line-height-normal" + @reset-date="$emit('reset-date', $event)" + /> + </div> + <div class="gl-display-flex gl-align-items-baseline" data-testid="sidebar-inherited-date"> + <gl-form-radio + v-model="dateIsFixed" + :value="false" + :disabled="!canUpdate || isLoading" + class="gl-pr-2" + > + <span :class="!dateIsFixed ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'"> + {{ $options.i18n.inherited }} + </span> + </gl-form-radio> + <sidebar-formatted-date + :has-date="!dateIsFixed" + :formatted-date="formattedInheritedDate" + :reset-text="$options.i18n.remove" + :is-loading="isLoading" + :can-delete="false" + class="gl-line-height-normal" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue b/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue deleted file mode 100644 index 141c2b3aae9..00000000000 --- a/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue +++ /dev/null @@ -1,203 +0,0 @@ -<script> -import { GlButton, GlIcon, GlDatepicker, GlTooltipDirective } from '@gitlab/ui'; -import createFlash from '~/flash'; -import { IssuableType } from '~/issue_show/constants'; -import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; -import { __, sprintf } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { dueDateQueries } from '~/sidebar/constants'; - -const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { - bubbles: true, -}); - -export default { - tracking: { - event: 'click_edit_button', - label: 'right_sidebar', - property: 'dueDate', - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - components: { - GlButton, - GlIcon, - GlDatepicker, - SidebarEditableItem, - }, - inject: ['fullPath', 'iid', 'canUpdate'], - props: { - issuableType: { - required: true, - type: String, - }, - }, - data() { - return { - dueDate: null, - loading: false, - }; - }, - apollo: { - dueDate: { - query() { - return dueDateQueries[this.issuableType].query; - }, - variables() { - return { - fullPath: this.fullPath, - iid: String(this.iid), - }; - }, - update(data) { - return data.workspace?.issuable?.dueDate || null; - }, - result({ data }) { - this.$emit('dueDateUpdated', data.workspace?.issuable?.dueDate); - }, - error() { - createFlash({ - message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), { - issuableType: this.issuableType, - }), - }); - }, - }, - }, - computed: { - isLoading() { - return this.$apollo.queries.dueDate.loading || this.loading; - }, - hasDueDate() { - return this.dueDate !== null; - }, - parsedDueDate() { - if (!this.hasDueDate) { - return null; - } - - return parsePikadayDate(this.dueDate); - }, - formattedDueDate() { - if (!this.hasDueDate) { - return this.$options.i18n.noDueDate; - } - - return dateInWords(this.parsedDueDate, true); - }, - workspacePath() { - return this.issuableType === IssuableType.Issue - ? { - projectPath: this.fullPath, - } - : { - groupPath: this.fullPath, - }; - }, - }, - methods: { - closeForm() { - this.$refs.editable.collapse(); - this.$el.dispatchEvent(hideDropdownEvent); - this.$emit('closeForm'); - }, - openDatePicker() { - this.$refs.datePicker.calendar.show(); - }, - setDueDate(date) { - this.loading = true; - this.$refs.editable.collapse(); - this.$apollo - .mutate({ - mutation: dueDateQueries[this.issuableType].mutation, - variables: { - input: { - ...this.workspacePath, - iid: this.iid, - dueDate: date ? formatDate(date, 'yyyy-mm-dd') : null, - }, - }, - }) - .then( - ({ - data: { - issuableSetDueDate: { errors }, - }, - }) => { - if (errors.length) { - createFlash({ - message: errors[0], - }); - } else { - this.$emit('closeForm'); - } - }, - ) - .catch(() => { - createFlash({ - message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), { - issuableType: this.issuableType, - }), - }); - }) - .finally(() => { - this.loading = false; - }); - }, - }, - i18n: { - dueDate: __('Due date'), - noDueDate: __('None'), - removeDueDate: __('remove due date'), - }, -}; -</script> - -<template> - <sidebar-editable-item - ref="editable" - :title="$options.i18n.dueDate" - :tracking="$options.tracking" - :loading="isLoading" - class="block" - data-testid="due-date" - @open="openDatePicker" - > - <template #collapsed> - <div v-gl-tooltip :title="$options.i18n.dueDate" class="sidebar-collapsed-icon"> - <gl-icon :size="16" name="calendar" /> - <span class="collapse-truncated-title">{{ formattedDueDate }}</span> - </div> - <div class="gl-display-flex gl-align-items-center hide-collapsed"> - <span - :class="hasDueDate ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'" - data-testid="sidebar-duedate-value" - > - {{ formattedDueDate }} - </span> - <div v-if="hasDueDate && canUpdate" class="gl-display-flex"> - <span class="gl-px-2">-</span> - <gl-button - variant="link" - class="gl-text-gray-500!" - data-testid="reset-button" - :disabled="isLoading" - @click="setDueDate(null)" - > - {{ $options.i18n.removeDueDate }} - </gl-button> - </div> - </div> - </template> - <template #default> - <gl-datepicker - ref="datePicker" - :value="parsedDueDate" - show-clear-button - @input="setDueDate" - @clear="setDueDate(null)" - /> - </template> - </sidebar-editable-item> -</template> diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue index c3a08f760a0..e85e416881c 100644 --- a/app/assets/javascripts/sidebar/components/participants/participants.vue +++ b/app/assets/javascripts/sidebar/components/participants/participants.vue @@ -95,7 +95,7 @@ export default { <gl-loading-icon v-if="loading" /> <span v-else data-testid="collapsed-count"> {{ participantCount }} </span> </div> - <div v-if="showParticipantLabel" class="title hide-collapsed"> + <div v-if="showParticipantLabel" class="title hide-collapsed gl-mb-2"> <gl-loading-icon v-if="loading" :inline="true" /> {{ participantLabel }} </div> @@ -105,10 +105,10 @@ export default { :key="participant.id" class="participants-author" > - <a :href="participant.web_url" class="author-link"> + <a :href="participant.web_url || participant.webUrl" class="author-link"> <user-avatar-image :lazy="true" - :img-src="participant.avatar_url" + :img-src="participant.avatar_url || participant.avatarUrl" :size="24" :tooltip-text="participant.name" css-classes="avatar-inline" diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue new file mode 100644 index 00000000000..d3043e6f6aa --- /dev/null +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue @@ -0,0 +1,68 @@ +<script> +import { __ } from '~/locale'; +import { participantsQueries } from '~/sidebar/constants'; +import Participants from './participants.vue'; + +export default { + i18n: { + fetchingError: __('An error occurred while fetching participants'), + }, + components: { + Participants, + }, + props: { + iid: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + participants: [], + }; + }, + apollo: { + participants: { + query() { + return participantsQueries[this.issuableType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + update(data) { + return data.workspace?.issuable?.participants.nodes || []; + }, + error(error) { + this.$emit('fetch-error', { + message: this.$options.i18n.fetchingError, + error, + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.participants.loading; + }, + }, +}; +</script> + +<template> + <participants + :loading="isLoading" + :participants="participants" + :number-of-less-participants="7" + /> +</template> diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index caf1c92c28a..0fb8d762c7c 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -3,6 +3,9 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; export default { + i18n: { + unassigned: __('Unassigned'), + }, components: { GlButton, GlLoadingIcon }, inject: { canUpdate: {}, @@ -40,6 +43,11 @@ export default { property: null, }), }, + canEdit: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -103,14 +111,16 @@ export default { <div> <div class="gl-display-flex gl-align-items-center" @click.self="collapse"> <span class="hide-collapsed" data-testid="title" @click="collapse">{{ title }}</span> + <slot name="title-extra"></slot> <gl-loading-icon v-if="loading || initialLoading" inline class="gl-ml-2 hide-collapsed" /> <gl-loading-icon v-if="loading && isClassicSidebar" inline class="gl-mx-auto gl-my-0 hide-expanded" /> + <slot name="collapsed-right"></slot> <gl-button - v-if="canUpdate && !initialLoading" + v-if="canUpdate && !initialLoading && canEdit" variant="link" class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed" data-testid="edit-button" diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue deleted file mode 100644 index 3ad097138a3..00000000000 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue +++ /dev/null @@ -1,42 +0,0 @@ -<script> -import { deprecatedCreateFlash as Flash } from '../../../flash'; -import { __ } from '../../../locale'; -import Store from '../../stores/sidebar_store'; -import subscriptions from './subscriptions.vue'; - -export default { - components: { - subscriptions, - }, - props: { - mediator: { - type: Object, - required: true, - }, - }, - data() { - return { - store: new Store(), - }; - }, - methods: { - onToggleSubscription() { - this.mediator.toggleSubscription().catch(() => { - Flash(__('Error occurred when toggling the notification subscription')); - }); - }, - }, -}; -</script> - -<template> - <div class="block subscriptions"> - <subscriptions - :loading="store.isFetching.subscriptions" - :project-emails-disabled="store.projectEmailsDisabled" - :subscribe-disabled-description="store.subscribeDisabledDescription" - :subscribed="store.subscribed" - @toggleSubscription="onToggleSubscription" - /> - </div> -</template> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue new file mode 100644 index 00000000000..ee7502e3457 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -0,0 +1,202 @@ +<script> +import { GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; +import { __, sprintf } from '~/locale'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { subscribedQueries } from '~/sidebar/constants'; + +const ICON_ON = 'notifications'; +const ICON_OFF = 'notifications-off'; + +export default { + tracking: { + event: 'click_edit_button', + label: 'right_sidebar', + property: 'subscriptions', + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlIcon, + GlLoadingIcon, + GlToggle, + SidebarEditableItem, + }, + inject: ['canUpdate'], + props: { + iid: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + subscribed: false, + loading: false, + emailsDisabled: false, + }; + }, + apollo: { + subscribed: { + query() { + return subscribedQueries[this.issuableType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.iid), + }; + }, + update(data) { + return data.workspace?.issuable?.subscribed || false; + }, + result({ data }) { + this.emailsDisabled = this.parentIsGroup + ? data.workspace?.emailsDisabled + : data.workspace?.issuable?.emailsDisabled; + this.$emit('subscribedUpdated', data.workspace?.issuable?.subscribed); + }, + error() { + createFlash({ + message: sprintf( + __('Something went wrong while setting %{issuableType} notifications.'), + { + issuableType: this.issuableType, + }, + ), + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries?.subscribed?.loading || this.loading; + }, + notificationTooltip() { + if (this.emailsDisabled) { + return this.subscribeDisabledDescription; + } + return this.subscribed ? this.$options.i18n.labelOn : this.$options.i18n.labelOff; + }, + notificationIcon() { + if (this.emailsDisabled || !this.subscribed) { + return ICON_OFF; + } + return ICON_ON; + }, + parentIsGroup() { + return this.issuableType === IssuableType.Epic; + }, + subscribeDisabledDescription() { + return sprintf(__('Disabled by %{parent} owner'), { + parent: this.parentIsGroup ? 'group' : 'project', + }); + }, + }, + methods: { + setSubscribed(subscribed) { + this.loading = true; + this.$apollo + .mutate({ + mutation: subscribedQueries[this.issuableType].mutation, + variables: { + fullPath: this.fullPath, + iid: this.iid, + subscribedState: subscribed, + }, + }) + .then( + ({ + data: { + updateIssuableSubscription: { errors }, + }, + }) => { + if (errors.length) { + createFlash({ + message: errors[0], + }); + } + }, + ) + .catch(() => { + createFlash({ + message: sprintf( + __('Something went wrong while setting %{issuableType} notifications.'), + { + issuableType: this.issuableType, + }, + ), + }); + }) + .finally(() => { + this.loading = false; + }); + }, + toggleSubscribed() { + if (this.emailsDisabled) { + this.expandSidebar(); + } else { + this.setSubscribed(!this.subscribed); + } + }, + expandSidebar() { + this.$emit('expandSidebar'); + }, + }, + i18n: { + notifications: __('Notifications'), + labelOn: __('Notifications on'), + labelOff: __('Notifications off'), + }, +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="$options.i18n.notifications" + :tracking="$options.tracking" + :loading="isLoading" + :can-edit="false" + class="block subscriptions" + > + <template #collapsed-right> + <gl-toggle + :value="subscribed" + :is-loading="isLoading" + :disabled="emailsDisabled || !canUpdate" + class="hide-collapsed gl-ml-auto" + data-testid="subscription-toggle" + :label="$options.i18n.notifications" + label-position="hidden" + @change="setSubscribed" + /> + </template> + <template #collapsed> + <span + ref="tooltip" + v-gl-tooltip.viewport.left + :title="notificationTooltip" + class="sidebar-collapsed-icon" + @click="toggleSubscribed" + > + <gl-loading-icon v-if="isLoading" class="sidebar-item-icon is-active" /> + <gl-icon v-else :name="notificationIcon" :size="16" class="sidebar-item-icon is-active" /> + </span> + <div v-show="emailsDisabled" class="gl-mt-3 hide-collapsed gl-text-gray-500"> + {{ subscribeDisabledDescription }} + </div> + </template> + <template #default> </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue new file mode 100644 index 00000000000..67242b3b5b7 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -0,0 +1,112 @@ +<script> +import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; +import { timelogQueries } from '~/sidebar/constants'; + +const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)'; + +export default { + components: { + GlLoadingIcon, + GlTable, + }, + inject: ['issuableId', 'issuableType'], + props: { + limitToHours: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { report: [], isLoading: true }; + }, + apollo: { + report: { + query() { + return timelogQueries[this.issuableType].query; + }, + variables() { + return { + id: convertToGraphQLId(this.getGraphQLEntityType(), this.issuableId), + }; + }, + update(data) { + this.isLoading = false; + return this.extractTimelogs(data); + }, + error() { + createFlash({ message: __('Something went wrong. Please try again.') }); + }, + }, + }, + methods: { + isIssue() { + return this.issuableType === 'issue'; + }, + getGraphQLEntityType() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return this.isIssue() ? 'Issue' : 'MergeRequest'; + }, + extractTimelogs(data) { + const timelogs = data?.issuable?.timelogs?.nodes || []; + return timelogs.slice().sort((a, b) => new Date(a.spentAt) - new Date(b.spentAt)); + }, + formatDate(date) { + return formatDate(date, TIME_DATE_FORMAT); + }, + getNote(note) { + return note?.body; + }, + getTotalTimeSpent() { + const seconds = this.report.reduce((acc, item) => acc + item.timeSpent, 0); + return this.formatTimeSpent(seconds); + }, + formatTimeSpent(seconds) { + const negative = seconds < 0; + return ( + (negative ? '- ' : '') + + stringifyTime(parseSeconds(seconds, { limitToHours: this.limitToHours })) + ); + }, + }, + fields: [ + { key: 'spentAt', label: __('Spent At'), sortable: true }, + { key: 'user', label: __('User'), sortable: true }, + { key: 'timeSpent', label: __('Time Spent'), sortable: true }, + { key: 'note', label: __('Note'), sortable: true }, + ], +}; +</script> + +<template> + <div> + <div v-if="isLoading"><gl-loading-icon size="md" /></div> + <gl-table v-else :items="report" :fields="$options.fields" foot-clone> + <template #cell(spentAt)="{ item: { spentAt } }"> + <div>{{ formatDate(spentAt) }}</div> + </template> + <template #foot(spentAt)> </template> + + <template #cell(user)="{ item: { user } }"> + <div>{{ user.name }}</div> + </template> + <template #foot(user)> </template> + + <template #cell(timeSpent)="{ item: { timeSpent } }"> + <div>{{ formatTimeSpent(timeSpent) }}</div> + </template> + <template #foot(timeSpent)> + <div>{{ getTotalTimeSpent() }}</div> + </template> + + <template #cell(note)="{ item: { note } }"> + <div>{{ getNote(note) }}</div> + </template> + <template #foot(note)> </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 4c095006dd7..64f2ddc1d16 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,10 +1,11 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlLink, GlModal, GlModalDirective } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import eventHub from '../../event_hub'; import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; import TimeTrackingHelpState from './help_state.vue'; +import TimeTrackingReport from './report.vue'; import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; export default { @@ -15,10 +16,16 @@ export default { }, components: { GlIcon, + GlLink, + GlModal, TimeTrackingCollapsedState, TimeTrackingSpentOnlyPane, TimeTrackingComparisonPane, TimeTrackingHelpState, + TimeTrackingReport, + }, + directives: { + GlModal: GlModalDirective, }, props: { timeEstimate: { @@ -160,6 +167,21 @@ export default { :time-estimate-human-readable="humanTimeEstimate" :limit-to-hours="limitToHours" /> + <gl-link + v-if="hasTimeSpent" + v-gl-modal="'time-tracking-report'" + data-testid="reportLink" + href="#" + class="btn-link" + >{{ __('Time tracking report') }}</gl-link + > + <gl-modal + modal-id="time-tracking-report" + :title="__('Time tracking report')" + :hide-footer="true" + > + <time-tracking-report :limit-to-hours="limitToHours" /> + </gl-modal> <transition name="help-state-toggle"> <time-tracking-help-state v-if="showHelpState" /> </transition> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 80e07d556bf..a4e6d8854d1 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,27 +1,56 @@ import { IssuableType } from '~/issue_show/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; +import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; +import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql'; +import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql'; +import epicSubscribedQuery from '~/sidebar/queries/epic_subscribed.query.graphql'; +import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; +import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql'; import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; -import updateEpicMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; +import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql'; +import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; +import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mutation.graphql'; +import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql'; +import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql'; import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql'; -import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; +import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql'; +import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; +import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; +import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; +import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql'; +import getMergeRequestAssignees from '~/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql'; import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql'; -import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; -import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; +import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; +import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; +import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; export const ASSIGNEES_DEBOUNCE_DELAY = 250; export const assigneesQueries = { [IssuableType.Issue]: { - query: getIssueParticipants, - mutation: updateAssigneesMutation, + query: getIssueAssignees, + subscription: issuableAssigneesSubscription, + mutation: updateIssueAssigneesMutation, + }, + [IssuableType.MergeRequest]: { + query: getMergeRequestAssignees, + mutation: updateMergeRequestAssigneesMutation, + }, +}; + +export const participantsQueries = { + [IssuableType.Issue]: { + query: issueParticipantsQuery, }, [IssuableType.MergeRequest]: { query: getMergeRequestParticipants, - mutation: updateMergeRequestParticipantsMutation, + }, + [IssuableType.Epic]: { + query: epicParticipantsQuery, }, }; @@ -32,7 +61,7 @@ export const confidentialityQueries = { }, [IssuableType.Epic]: { query: epicConfidentialQuery, - mutation: updateEpicMutation, + mutation: updateEpicConfidentialMutation, }, }; @@ -45,9 +74,62 @@ export const referenceQueries = { }, }; +export const dateTypes = { + start: 'startDate', + due: 'dueDate', +}; + +export const dateFields = { + [dateTypes.start]: { + isDateFixed: 'startDateIsFixed', + dateFixed: 'startDateFixed', + dateFromMilestones: 'startDateFromMilestones', + }, + [dateTypes.due]: { + isDateFixed: 'dueDateIsFixed', + dateFixed: 'dueDateFixed', + dateFromMilestones: 'dueDateFromMilestones', + }, +}; + +export const subscribedQueries = { + [IssuableType.Issue]: { + query: issueSubscribedQuery, + mutation: updateIssueSubscriptionMutation, + }, + [IssuableType.Epic]: { + query: epicSubscribedQuery, + mutation: updateEpicSubscriptionMutation, + }, + [IssuableType.MergeRequest]: { + query: mergeRequestSubscribed, + mutation: updateMergeRequestSubscriptionMutation, + }, +}; + export const dueDateQueries = { [IssuableType.Issue]: { query: issueDueDateQuery, mutation: updateIssueDueDateMutation, }, + [IssuableType.Epic]: { + query: epicDueDateQuery, + mutation: updateEpicDueDateMutation, + }, +}; + +export const startDateQueries = { + [IssuableType.Epic]: { + query: epicStartDateQuery, + mutation: updateEpicStartDateMutation, + }, +}; + +export const timelogQueries = { + [IssuableType.Issue]: { + query: getIssueTimelogsQuery, + }, + [IssuableType.MergeRequest]: { + query: getMrTimelogsQuery, + }, }; diff --git a/app/assets/javascripts/sidebar/fragmentTypes.json b/app/assets/javascripts/sidebar/fragmentTypes.json new file mode 100644 index 00000000000..a1c68bba454 --- /dev/null +++ b/app/assets/javascripts/sidebar/fragmentTypes.json @@ -0,0 +1 @@ +{"__schema":{"types":[{"kind":"UNION","name":"Issuable","possibleTypes":[{"name":"Issue"},{"name":"MergeRequest"}]}, {"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]}]}} diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js index aa139540a51..8615b52f1b8 100644 --- a/app/assets/javascripts/sidebar/graphql.js +++ b/app/assets/javascripts/sidebar/graphql.js @@ -1,7 +1,21 @@ +import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import introspectionQueryResultData from './fragmentTypes.json'; -export const defaultClient = createDefaultClient(); +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + +export const defaultClient = createDefaultClient( + {}, + { + cacheConfig: { + fragmentMatcher, + }, + assumeImmutableResults: true, + }, +); export const apolloProvider = new VueApollo({ defaultClient, diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 1304e84814b..3f24fdc75dc 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -13,7 +13,7 @@ import { __ } from '~/locale'; import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; -import SidebarDueDateWidget from '~/sidebar/components/due_date/sidebar_due_date_widget.vue'; +import SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; import { apolloProvider } from '~/sidebar/graphql'; import Translate from '../vue_shared/translate'; @@ -24,7 +24,7 @@ import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import sidebarParticipants from './components/participants/sidebar_participants.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; -import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; +import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; @@ -53,7 +53,7 @@ function mountAssigneesComponentDeprecated(mediator) { if (!el) return; - const { iid, fullPath } = getSidebarOptions(); + const { id, iid, fullPath } = getSidebarOptions(); const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData(); // eslint-disable-next-line no-new new Vue({ @@ -74,6 +74,7 @@ function mountAssigneesComponentDeprecated(mediator) { isInIssuePage() || isInIncidentPage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, + issuableId: id, assigneeAvailabilityStatus, }, }), @@ -85,7 +86,7 @@ function mountAssigneesComponent() { if (!el) return; - const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions(); + const { id, iid, fullPath, editable } = getSidebarOptions(); // eslint-disable-next-line no-new new Vue({ el, @@ -95,9 +96,7 @@ function mountAssigneesComponent() { }, provide: { canUpdate: editable, - projectMembersPath, directlyInviteMembers: el.hasAttribute('data-directly-invite-members'), - indirectlyInviteMembers: el.hasAttribute('data-indirectly-invite-members'), }, render: (createElement) => createElement('sidebar-assignees-widget', { @@ -108,7 +107,8 @@ function mountAssigneesComponent() { isInIssuePage() || isInIncidentPage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest, - multipleAssignees: !el.dataset.maxAssignees, + issuableId: id, + allowMultipleAssignees: !el.dataset.maxAssignees, }, scopedSlots: { collapsed: ({ users, onClick }) => @@ -223,14 +223,14 @@ function mountDueDateComponent() { SidebarDueDateWidget, }, provide: { - iid: String(iid), - fullPath, canUpdate: editable, }, render: (createElement) => createElement('sidebar-due-date-widget', { props: { + iid: String(iid), + fullPath, issuableType: IssuableType.Issue, }, }), @@ -334,21 +334,32 @@ function mountParticipantsComponent(mediator) { }); } -function mountSubscriptionsComponent(mediator) { +function mountSubscriptionsComponent() { const el = document.querySelector('.js-sidebar-subscriptions-entry-point'); if (!el) return; + const { fullPath, iid, editable } = getSidebarOptions(); + // eslint-disable-next-line no-new new Vue({ el, + apolloProvider, components: { - sidebarSubscriptions, + SidebarSubscriptionsWidget, + }, + provide: { + canUpdate: editable, }, render: (createElement) => - createElement('sidebar-subscriptions', { + createElement('sidebar-subscriptions-widget', { props: { - mediator, + iid: String(iid), + fullPath, + issuableType: + isInIssuePage() || isInIncidentPage() || isInDesignPage() + ? IssuableType.Issue + : IssuableType.MergeRequest, }, }), }); @@ -356,16 +367,16 @@ function mountSubscriptionsComponent(mediator) { function mountTimeTrackingComponent() { const el = document.getElementById('issuable-time-tracker'); + const { id, issuableType } = getSidebarOptions(); if (!el) return; // eslint-disable-next-line no-new new Vue({ el, - components: { - SidebarTimeTracking, - }, - render: (createElement) => createElement('sidebar-time-tracking', {}), + apolloProvider, + provide: { issuableId: id, issuableType }, + render: (createElement) => createElement(SidebarTimeTracking, {}), }); } @@ -425,7 +436,7 @@ export function mountSidebar(mediator) { mountReferenceComponent(mediator); mountLockComponent(); mountParticipantsComponent(mediator); - mountSubscriptionsComponent(mediator); + mountSubscriptionsComponent(); mountCopyEmailComponent(); new SidebarMoveIssue( diff --git a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql new file mode 100644 index 00000000000..f60f44abebd --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql @@ -0,0 +1,13 @@ +query epicDueDate($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + __typename + issuable: epic(iid: $iid) { + __typename + id + dueDate + dueDateIsFixed + dueDateFixed + dueDateFromMilestones + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql new file mode 100644 index 00000000000..fbebc50ab08 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql @@ -0,0 +1,18 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query epicParticipants($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + __typename + issuable: epic(iid: $iid) { + __typename + id + participants { + nodes { + ...User + ...UserAvailability + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql new file mode 100644 index 00000000000..c6c24fd3d95 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql @@ -0,0 +1,13 @@ +query epicStartDate($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + __typename + issuable: epic(iid: $iid) { + __typename + id + startDate + startDateIsFixed + startDateFixed + startDateFromMilestones + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql new file mode 100644 index 00000000000..9f1967e1685 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql @@ -0,0 +1,11 @@ +query epicSubscribed($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + __typename + emailsDisabled + issuable: epic(iid: $iid) { + __typename + id + subscribed + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql new file mode 100644 index 00000000000..47ce094418c --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql @@ -0,0 +1,16 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +subscription issuableAssigneesUpdated($issuableId: IssuableID!) { + issuableAssigneesUpdated(issuableId: $issuableId) { + ... on Issue { + assignees { + nodes { + ...User + status { + availability + } + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql new file mode 100644 index 00000000000..7d38b5d3bd8 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql @@ -0,0 +1,11 @@ +query issueSubscribed($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: issue(iid: $iid) { + __typename + id + subscribed + emailsDisabled + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql new file mode 100644 index 00000000000..3b54a2e529b --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql @@ -0,0 +1,10 @@ +query mergeRequestSubscribed($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: mergeRequest(iid: $iid) { + __typename + id + subscribed + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql new file mode 100644 index 00000000000..9b0a8b4a8f7 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql @@ -0,0 +1,11 @@ +mutation updateEpicDueDate($input: UpdateEpicInput!) { + issuableSetDate: updateEpic(input: $input) { + issuable: epic { + id + dueDateIsFixed + dueDateFixed + dueDateFromMilestones + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql new file mode 100644 index 00000000000..9b4bb9159c3 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql @@ -0,0 +1,11 @@ +mutation updateEpicStartDate($input: UpdateEpicInput!) { + issuableSetDate: updateEpic(input: $input) { + issuable: epic { + id + startDateIsFixed + startDateFixed + startDateFromMilestones + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql index f2b806102f4..af43766aed5 100644 --- a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql @@ -1,6 +1,9 @@ -mutation epicSetSubscription($input: EpicSetSubscriptionInput!) { - updateIssuableSubscription: epicSetSubscription(input: $input) { - epic { +mutation epicSetSubscription($fullPath: ID!, $iid: ID!, $subscribedState: Boolean!) { + updateIssuableSubscription: epicSetSubscription( + input: { groupPath: $fullPath, iid: $iid, subscribedState: $subscribedState } + ) { + issuable: epic { + id subscribed } errors diff --git a/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql index cf7eccd61c7..4765b0b08cc 100644 --- a/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql @@ -1,5 +1,5 @@ mutation updateIssueDueDate($input: UpdateIssueInput!) { - issuableSetDueDate: updateIssue(input: $input) { + issuableSetDate: updateIssue(input: $input) { issuable: issue { id dueDate diff --git a/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql new file mode 100644 index 00000000000..81891fb601f --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql @@ -0,0 +1,11 @@ +mutation issueSetSubscription($fullPath: ID!, $iid: String!, $subscribedState: Boolean!) { + updateIssuableSubscription: issueSetSubscription( + input: { projectPath: $fullPath, iid: $iid, subscribedState: $subscribedState } + ) { + issuable: issue { + id + subscribed + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_subscription.mutation.graphql new file mode 100644 index 00000000000..69944ff9a13 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_merge_request_subscription.mutation.graphql @@ -0,0 +1,11 @@ +mutation mergeRequestSetSubscription($fullPath: ID!, $iid: String!, $subscribedState: Boolean!) { + updateIssuableSubscription: mergeRequestSetSubscription( + input: { projectPath: $fullPath, iid: $iid, subscribedState: $subscribedState } + ) { + issuable: mergeRequest { + id + subscribed + } + errors + } +} diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index 5fb20b00705..b08bf26e1dc 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -1,7 +1,6 @@ import { s__, __ } from '~/locale'; export const BRANCH_SUFFIX_COUNT = 8; -export const DEFAULT_TARGET_BRANCH = 'master'; export const ISSUABLE_TYPE = 'merge_request'; export const SUBMIT_CHANGES_BRANCH_ERROR = s__('StaticSiteEditor|Branch could not be created.'); diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js index 0b74c99b319..e9f1828bff8 100644 --- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js @@ -9,6 +9,7 @@ const submitContentChangesResolver = ( project: projectId, username, sourcePath, + targetBranch, content, images, mergeRequestMeta, @@ -21,6 +22,7 @@ const submitContentChangesResolver = ( projectId, username, sourcePath, + targetBranch, content, images, mergeRequestMeta, diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index 22f80ead74b..49a2ca03ace 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -111,6 +111,7 @@ export default { project: this.appData.project, username: this.appData.username, sourcePath: this.appData.sourcePath, + targetBranch: this.appData.branch, content: this.content, formattedMarkdown: this.formattedMarkdown, images: this.images, diff --git a/app/assets/javascripts/static_site_editor/services/generate_branch_name.js b/app/assets/javascripts/static_site_editor/services/generate_branch_name.js index f45ad616332..cbf03a41ce2 100644 --- a/app/assets/javascripts/static_site_editor/services/generate_branch_name.js +++ b/app/assets/javascripts/static_site_editor/services/generate_branch_name.js @@ -1,8 +1,8 @@ -import { BRANCH_SUFFIX_COUNT, DEFAULT_TARGET_BRANCH } from '../constants'; +import { BRANCH_SUFFIX_COUNT } from '../constants'; const generateBranchSuffix = () => `${Date.now()}`.substr(BRANCH_SUFFIX_COUNT); -const generateBranchName = (username, targetBranch = DEFAULT_TARGET_BRANCH) => +const generateBranchName = (username, targetBranch) => `${username}-${targetBranch}-patch-${generateBranchSuffix()}`; export default generateBranchName; diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js index 6391cfd6cc2..ecb7f60a421 100644 --- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js +++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js @@ -4,7 +4,6 @@ import generateBranchName from '~/static_site_editor/services/generate_branch_na import Tracking from '~/tracking'; import { - DEFAULT_TARGET_BRANCH, SUBMIT_CHANGES_BRANCH_ERROR, SUBMIT_CHANGES_COMMIT_ERROR, SUBMIT_CHANGES_MERGE_REQUEST_ERROR, @@ -16,9 +15,9 @@ import { DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION, } from '../constants'; -const createBranch = (projectId, branch) => +const createBranch = (projectId, branch, targetBranch) => Api.createBranch(projectId, { - ref: DEFAULT_TARGET_BRANCH, + ref: targetBranch, branch, }).catch(() => { throw new Error(SUBMIT_CHANGES_BRANCH_ERROR); @@ -73,13 +72,7 @@ const commit = (projectId, message, branch, actions) => { }); }; -const createMergeRequest = ( - projectId, - title, - description, - sourceBranch, - targetBranch = DEFAULT_TARGET_BRANCH, -) => { +const createMergeRequest = (projectId, title, description, sourceBranch, targetBranch) => { Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_MERGE_REQUEST); Api.trackRedisCounterEvent(USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST); @@ -100,16 +93,17 @@ const submitContentChanges = ({ username, projectId, sourcePath, + targetBranch, content, images, mergeRequestMeta, formattedMarkdown, }) => { - const branch = generateBranchName(username); + const branch = generateBranchName(username, targetBranch); const { title: mergeRequestTitle, description: mergeRequestDescription } = mergeRequestMeta; const meta = {}; - return createBranch(projectId, branch) + return createBranch(projectId, branch, targetBranch) .then(({ data: { web_url: url } }) => { const message = `${DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE}\n\n${DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION}`; @@ -133,7 +127,13 @@ const submitContentChanges = ({ .then(({ data: { short_id: label, web_url: url } }) => { Object.assign(meta, { commit: { label, url } }); - return createMergeRequest(projectId, mergeRequestTitle, mergeRequestDescription, branch); + return createMergeRequest( + projectId, + mergeRequestTitle, + mergeRequestDescription, + branch, + targetBranch, + ); }) .then(({ data: { iid: label, web_url: url } }) => { Object.assign(meta, { mergeRequest: { label: label.toString(), url } }); diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index bc8a8e425dd..3b2210b9ef2 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -40,20 +40,37 @@ export default class TaskList { taskListField.value = taskListField.dataset.value; }); - $(this.taskListContainerSelector).taskList('enable'); - $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler); + this.enable(); } getTaskListTarget(e) { return e && e.currentTarget ? $(e.currentTarget) : $(this.taskListContainerSelector); } + // Disable any task items that don't have a data-sourcepos attribute, on the + // assumption that if it doesn't then it wasn't generated from our markdown parser. + // This covers the case of markdown not being able to handle task lists inside + // markdown tables. It also includes hand coded HTML lists. + disableNonMarkdownTaskListItems(e) { + this.getTaskListTarget(e) + .find('.task-list-item') + .not('[data-sourcepos]') + .find('.task-list-item-checkbox') + .prop('disabled', true); + } + disableTaskListItems(e) { this.getTaskListTarget(e).taskList('disable'); } enableTaskListItems(e) { this.getTaskListTarget(e).taskList('enable'); + this.disableNonMarkdownTaskListItems(e); + } + + enable() { + this.enableTaskListItems(); + $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler); } disable() { diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index cdfecceb78a..d2e69bc06cf 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -153,6 +153,21 @@ export default class Tracking { return loadEvents; } + static enableFormTracking(config, contexts = []) { + if (!this.enabled()) return; + + if (!config?.forms?.whitelist?.length && !config?.fields?.whitelist?.length) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Unable to enable form event tracking without whitelist rules.'); + } + + contexts.unshift(STANDARD_CONTEXT); + const enabler = () => window.snowplow('enableFormTracking', config, contexts); + + if (document.readyState !== 'loading') enabler(); + else document.addEventListener('DOMContentLoaded', enabler); + } + static mixin(opts = {}) { return { computed: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue index abc831c8abe..a5d165ebd49 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -1,5 +1,12 @@ <script> -import { GlButtonGroup, GlDropdown, GlDropdownItem, GlLink, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlIcon, + GlLink, + GlSearchBoxByType, +} from '@gitlab/ui'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import ReviewAppLink from '../review_app_link.vue'; @@ -9,6 +16,7 @@ export default { GlButtonGroup, GlDropdown, GlDropdownItem, + GlIcon, GlLink, GlSearchBoxByType, ReviewAppLink, @@ -71,7 +79,14 @@ export default { size="small" css-class="deploy-link js-deploy-url inline" /> - <gl-dropdown size="small" class="js-mr-wigdet-deployment-dropdown"> + <gl-dropdown toggle-class="gl-px-2!" size="small" class="js-mr-wigdet-deployment-dropdown"> + <template #button-content> + <gl-icon + class="dropdown-chevron gl-mx-0!" + name="chevron-down" + data-testid="mr-wigdet-deployment-dropdown-icon" + /> + </template> <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> <gl-dropdown-item v-for="change in filteredChanges" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 1248a891ed9..fa46b4b1364 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -107,9 +107,6 @@ export default { hasCommitInfo() { return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0; }, - hasArtifacts() { - return this.pipeline?.details?.artifacts?.length > 0; - }, isMergeRequestPipeline() { return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline); }, @@ -288,11 +285,7 @@ export default { /> </span> <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" /> - <pipeline-artifacts - v-if="hasArtifacts" - :artifacts="pipeline.details.artifacts" - class="gl-ml-3" - /> + <pipeline-artifacts :pipeline-id="pipeline.id" class="gl-ml-3" /> </span> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 6d68c15cf2d..0cd280c42d2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -154,7 +154,7 @@ export default { <status-icon status="success" /> <div class="media-body"> <h4 class="gl-display-flex"> - <span class="gl-mr-3" data-qa-selector="merge_request_status_content"> + <span class="gl-mr-3"> <span class="js-status-text-before-author" data-testid="beforeStatusText">{{ statusTextBeforeAuthor }}</span> @@ -169,6 +169,7 @@ export default { role="button" href="#" class="btn btn-sm btn-default js-cancel-auto-merge" + data-qa-selector="cancel_auto_merge_button" data-testid="cancelAutomaticMergeButton" @click.prevent="cancelAutomaticMerge" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue index 0655eef6504..32749b8b018 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue @@ -1,4 +1,5 @@ <script> +import { MERGE_ACTIVE_STATUS_PHRASES } from '../../constants'; import statusIcon from '../mr_widget_status_icon.vue'; export default { @@ -13,13 +14,23 @@ export default { default: () => ({}), }, }, + data() { + const statusCount = MERGE_ACTIVE_STATUS_PHRASES.length; + + return { + mergeStatus: MERGE_ACTIVE_STATUS_PHRASES[Math.floor(Math.random() * statusCount)], + }; + }, }; </script> <template> <div class="mr-widget-body mr-state-locked media"> <status-icon status="loading" /> <div class="media-body"> - <h4>{{ s__('mrWidget|This merge request is in the process of being merged') }}</h4> + <h4> + {{ mergeStatus.message }} + <gl-emoji :data-name="mergeStatus.emoji" /> + </h4> <section class="mr-info-list"> <p> {{ s__('mrWidget|The changes will be merged into') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 33ca582583b..a82a8a22873 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -175,7 +175,7 @@ export default { > <gl-button :loading="isMakingRequest" - variant="success" + variant="confirm" data-qa-selector="mr_rebase_button" @click="rebase" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 329964d009a..c6ce29acb09 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -28,7 +28,7 @@ export default { <status-icon :show-disabled-button="true" status="warning" /> <div class="media-body"> <span class="gl-ml-3 gl-font-weight-bold gl-display-block gl-w-100">{{ - s__('mrWidget|Before this can be merged, one or more threads must be resolved.') + s__('mrWidget|Merge blocked: all threads must be resolved.') }}</span> <gl-button data-testid="jump-to-first" diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 77dfbf9d385..822fb58db60 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -25,3 +25,30 @@ export const SP_HELP_CONTENT = s__( ); export const SP_HELP_URL = 'https://about.gitlab.com/blog/2019/07/12/guide-to-ci-cd-pipelines/'; export const SP_ICON_NAME = 'status_notfound'; + +export const MERGE_ACTIVE_STATUS_PHRASES = [ + { + message: s__('mrWidget|Merging! Drum roll, please…'), + emoji: 'drum', + }, + { + message: s__("mrWidget|Merging! We're almost there…"), + emoji: 'sparkles', + }, + { + message: s__('mrWidget|Merging! Changes will land soon…'), + emoji: 'airplane_arriving', + }, + { + message: s__('mrWidget|Merging! Changes are being shipped…'), + emoji: 'ship', + }, + { + message: s__("mrWidget|Merging! Everything's good…"), + emoji: 'relieved', + }, + { + message: s__('mrWidget|Merging! This is going to be great…'), + emoji: 'heart_eyes', + }, +]; diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index c1c491f6fe0..3a3a1329483 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -32,6 +32,10 @@ export default () => { const vm = new Vue({ el: '#js-vue-mr-widget', + provide: { + artifactsEndpoint: gl.mrWidgetData.artifacts_endpoint, + artifactsEndpointPlaceholder: gl.mrWidgetData.artifacts_endpoint_placeholder, + }, ...MrWidgetOptions, apolloProvider, }); diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js index 23e140623cc..67d9892d9c6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js @@ -9,7 +9,7 @@ export default { return s__('mrWidget|to be merged automatically when the pipeline succeeds'); }, cancelButtonText() { - return s__('mrWidget|Cancel automatic merge'); + return s__('mrWidget|Cancel'); }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 264ea36137f..0cfb059b0ce 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -460,9 +460,6 @@ export default { <grouped-codequality-reports-app v-if="shouldRenderCodeQuality" :base-path="mr.codeclimate.base_path" - :head-path="mr.codeclimate.head_path" - :head-blob-path="mr.headBlobPath" - :base-blob-path="mr.baseBlobPath" :codequality-reports-path="mr.codequalityReportsPath" :codequality-help-path="mr.codequalityHelpPath" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index f57b638dd81..9f85140bab8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,6 +1,6 @@ import { format } from 'timeago.js'; import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; -import mrEventHub from '~/merge_request/eventhub'; +import { statusBoxState } from '~/issuable/components/status_box.vue'; import { formatDate } from '../../lib/utils/datetime_utility'; import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWPS_MERGE_STRATEGY } from '../constants'; import { stateKey } from './state_maps'; @@ -23,6 +23,8 @@ export default class MergeRequestStore { setData(data, isRebased) { this.initApprovals(); + this.updateStatusState(data.state); + if (isRebased) { this.sha = data.diff_head_sha; } @@ -156,16 +158,14 @@ export default class MergeRequestStore { this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false; this.setState(); - - if (!window.gon?.features?.mergeRequestWidgetGraphql) { - this.emitUpdatedState(); - } } setGraphqlData(project) { const { mergeRequest } = project; const pipeline = mergeRequest.headPipeline; + this.updateStatusState(mergeRequest.state); + this.projectArchived = project.archived; this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds; @@ -190,10 +190,15 @@ export default class MergeRequestStore { this.workInProgress = mergeRequest.workInProgress; this.mergeRequestState = mergeRequest.state; - this.emitUpdatedState(); this.setState(); } + updateStatusState(state) { + if (this.mergeRequestState !== state && statusBoxState.updateStatus) { + statusBoxState.updateStatus(); + } + } + setState() { if (this.mergeOngoing) { this.state = 'merging'; @@ -216,12 +221,6 @@ export default class MergeRequestStore { } } - emitUpdatedState() { - mrEventHub.$emit('mr.state.updated', { - state: this.mergeRequestState, - }); - } - setPaths(data) { // Paths are set on the first load of the page and not auto-refreshed this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path; diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue index 554c7a573fe..ca42cb0b1b5 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue @@ -64,6 +64,9 @@ export default { <sidebar-status :project-path="projectPath" :alert="alert" + :sidebar-collapsed="sidebarStatus" + text-class="gl-text-gray-500" + class="gl-w-70p" @toggle-sidebar="$emit('toggle-sidebar')" @alert-error="$emit('alert-error', $event)" /> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index 2a999b908f9..ef31106b709 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -192,21 +192,33 @@ export default { </script> <template> - <div class="block alert-assignees"> - <div ref="assignees" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')"> - <gl-icon name="user" :size="14" /> - <gl-loading-icon v-if="isUpdating" /> - </div> - <gl-tooltip :target="() => $refs.assignees" boundary="viewport" placement="left"> - <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK"> - <template #assignees> - {{ userName }} - </template> - </gl-sprintf> - </gl-tooltip> + <div + class="alert-assignees gl-py-5 gl-w-70p" + :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': !sidebarCollapsed }" + > + <template v-if="sidebarCollapsed"> + <div + ref="assignees" + class="gl-mb-6 gl-ml-6" + data-testid="assignees-icon" + @click="$emit('toggle-sidebar')" + > + <gl-icon name="user" /> + <gl-loading-icon v-if="isUpdating" /> + </div> + <gl-tooltip :target="() => $refs.assignees" boundary="viewport" placement="left"> + <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK"> + <template #assignees> + {{ userName }} + </template> + </gl-sprintf> + </gl-tooltip> + </template> - <div class="hide-collapsed"> - <p class="title gl-display-flex gl-justify-content-space-between"> + <div v-else> + <p + class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between" + > {{ __('Assignee') }} <a v-if="isEditable" @@ -264,7 +276,11 @@ export default { </div> <gl-loading-icon v-if="isUpdating" :inline="true" /> - <div v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }"> + <div + v-else-if="!isDropdownShowing" + class="hide-collapsed value gl-m-0" + :class="{ 'no-value': !userName }" + > <div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users"> <span class="gl-relative gl-mr-4"> <img diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue index fd40b5d9f65..832b154b312 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue @@ -25,7 +25,7 @@ export default { </script> <template> - <div class="block gl-display-flex gl-justify-content-space-between"> + <div class="block gl-display-flex gl-justify-content-space-between gl-border-b-gray-100!"> <span class="issuable-header-text hide-collapsed"> {{ __('To Do') }} </span> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue index 3822b9153a4..8715eb99518 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue @@ -30,6 +30,15 @@ export default { required: false, default: true, }, + sidebarCollapsed: { + type: Boolean, + required: false, + }, + textClass: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -48,34 +57,44 @@ export default { }, toggleFormDropdown() { this.isDropdownShowing = !this.isDropdownShowing; - const { dropdown } = this.$children[2].$refs.dropdown.$refs; + const { dropdown } = this.$refs.status.$refs.dropdown.$refs; if (dropdown && this.isDropdownShowing) { dropdown.show(); } }, - handleUpdating(updating) { - this.isUpdating = updating; + handleUpdating(isMutationInProgress) { + if (!isMutationInProgress) { + this.$emit('alert-update'); + } + this.isUpdating = isMutationInProgress; }, }, }; </script> <template> - <div class="block alert-status"> - <div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')"> - <gl-icon name="status" :size="14" /> - <gl-loading-icon v-if="isUpdating" /> - </div> - <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left"> - <gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')"> - <template #status> - {{ alert.status.toLowerCase() }} - </template> - </gl-sprintf> - </gl-tooltip> + <div + class="alert-status gl-py-5" + :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': !sidebarCollapsed }" + > + <template v-if="sidebarCollapsed"> + <div ref="status" class="gl-ml-6" data-testid="status-icon" @click="$emit('toggle-sidebar')"> + <gl-icon name="status" /> + <gl-loading-icon v-if="isUpdating" /> + </div> + <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left"> + <gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')"> + <template #status> + {{ alert.status.toLowerCase() }} + </template> + </gl-sprintf> + </gl-tooltip> + </template> - <div class="hide-collapsed"> - <p class="title gl-display-flex justify-content-between"> + <div v-else> + <p + class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between" + > {{ s__('AlertManagement|Status') }} <a v-if="isEditable" @@ -90,6 +109,7 @@ export default { </p> <alert-status + ref="status" :alert="alert" :project-path="projectPath" :is-dropdown-showing="isDropdownShowing" @@ -106,7 +126,7 @@ export default { class="value gl-m-0" :class="{ 'no-value': !statuses[alert.status] }" > - <span v-if="statuses[alert.status]" class="gl-text-gray-500" data-testid="status"> + <span v-if="statuses[alert.status]" :class="textClass" data-testid="status"> {{ statuses[alert.status] }} </span> <span v-else> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue index 271f0b4e4bb..a2a4046ab81 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue @@ -134,7 +134,12 @@ export default { </script> <template> - <div :class="{ 'block todo': sidebarCollapsed, 'gl-ml-auto': !sidebarCollapsed }"> + <div + :class="{ + 'block todo': sidebarCollapsed, + 'gl-ml-auto': !sidebarCollapsed, + }" + > <todo data-testid="alert-todo-button" :collapsed="sidebarCollapsed" diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql index bc4d91a51d1..f0095abfca1 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql @@ -3,6 +3,7 @@ mutation createAlertIssue($projectPath: ID!, $iid: String!) { errors issue { iid + webUrl } } } diff --git a/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue new file mode 100644 index 00000000000..1f293b2150f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue @@ -0,0 +1,41 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; + +export default { + components: { + GlAlert, + GlLink, + GlSprintf, + }, + inject: ['hasManagedPrometheus'], + i18n: { + alertsDeprecationText: s__( + 'Metrics|GitLab-managed Prometheus is deprecated and %{linkStart}scheduled for removal%{linkEnd}. Following this removal, your existing alerts will continue to function as part of the new cluster integration. However, you will no longer be able to add new alerts or edit existing alerts from the metrics dashboard.', + ), + }, + methods: { + helpPagePath, + }, +}; +</script> + +<template> + <gl-alert v-if="hasManagedPrometheus" variant="warning" class="my-2"> + <gl-sprintf :message="$options.i18n.alertsDeprecationText"> + <template #link="{ content }"> + <gl-link + :href=" + helpPagePath('operations/metrics/alerts.html', { + anchor: 'managed-prometheus-instances', + }) + " + target="_blank" + > + <span>{{ content }}</span> + </gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue index f477610ff1d..f6ab3cac536 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -6,6 +6,7 @@ import { HIGHLIGHT_CLASS_NAME } from './constants'; import ViewerMixin from './mixins'; export default { + name: 'SimpleViewer', components: { GlIcon, EditorLite: () => diff --git a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue index ad3e6713e45..2552236a073 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue @@ -1,7 +1,7 @@ <script> import { GlAreaChart } from '@gitlab/ui/dist/charts'; import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; -import { CHART_CONTAINER_HEIGHT } from '../constants'; +import { CHART_CONTAINER_HEIGHT } from './constants'; export default { name: 'CiCdAnalyticsAreaChart', diff --git a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue index f4fd57e4cdc..f4fd57e4cdc 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js new file mode 100644 index 00000000000..1561674c0ad --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js @@ -0,0 +1 @@ +export const CHART_CONTAINER_HEIGHT = 300; diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue index eb8400e81c7..a1c7c4dd142 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -28,6 +28,7 @@ export default { </script> <template> + <!-- eslint-disable @gitlab/vue-no-data-toggle --> <button :disabled="isDisabled || isLoading" class="dropdown-menu-toggle dropdown-menu-full-width" diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js index e622b505570..e1e71639115 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js @@ -93,6 +93,7 @@ const fileExtensionIcons = { pdf: 'pdf', xlsx: 'table', xls: 'table', + ods: 'table', csv: 'table', tsv: 'table', vscodeignore: 'vscode', @@ -154,6 +155,7 @@ const fileExtensionIcons = { gradle: 'gradle', doc: 'word', docx: 'word', + odt: 'word', rtf: 'word', cer: 'certificate', cert: 'certificate', @@ -204,6 +206,7 @@ const fileExtensionIcons = { pps: 'powerpoint', ppam: 'powerpoint', ppa: 'powerpoint', + odp: 'powerpoint', webm: 'movie', mkv: 'movie', flv: 'movie', diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 3d8afd162cb..2cb1b6a195f 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -1,24 +1,46 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import { __ } from '~/locale'; -const DEFAULT_LABEL_NO_LABEL = { value: 'No label', text: __('No label') }; -export const DEFAULT_LABEL_NONE = { value: 'None', text: __('None') }; -export const DEFAULT_LABEL_ANY = { value: 'Any', text: __('Any') }; +export const DEBOUNCE_DELAY = 200; +export const MAX_RECENT_TOKENS_SIZE = 3; -export const DEFAULT_LABELS = [DEFAULT_LABEL_NO_LABEL]; +export const FILTER_NONE = 'None'; +export const FILTER_ANY = 'Any'; +export const FILTER_CURRENT = 'Current'; -export const DEBOUNCE_DELAY = 200; +export const OPERATOR_IS = '='; +export const OPERATOR_IS_TEXT = __('is'); +export const OPERATOR_IS_NOT = '!='; + +export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }]; + +export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __(FILTER_NONE) }; +export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __(FILTER_ANY) }; +export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + +export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([ + { value: FILTER_CURRENT, text: __(FILTER_CURRENT) }, +]); + +export const DEFAULT_LABELS = [{ value: 'No label', text: __('No label') }]; // eslint-disable-line @gitlab/require-i18n-strings + +export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ + { value: 'Upcoming', text: __('Upcoming') }, // eslint-disable-line @gitlab/require-i18n-strings + { value: 'Started', text: __('Started') }, // eslint-disable-line @gitlab/require-i18n-strings +]); export const SortDirection = { descending: 'descending', ascending: 'ascending', }; -export const DEFAULT_MILESTONES = [ - DEFAULT_LABEL_NONE, - DEFAULT_LABEL_ANY, - { value: 'Upcoming', text: __('Upcoming') }, - { value: 'Started', text: __('Started') }, -]; +export const FILTERED_SEARCH_TERM = 'filtered-search-term'; -/* eslint-enable @gitlab/require-i18n-strings */ +export const TOKEN_TITLE_AUTHOR = __('Author'); +export const TOKEN_TITLE_ASSIGNEE = __('Assignee'); +export const TOKEN_TITLE_MILESTONE = __('Milestone'); +export const TOKEN_TITLE_LABEL = __('Label'); +export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); +export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); +export const TOKEN_TITLE_ITERATION = __('Iteration'); +export const TOKEN_TITLE_EPIC = __('Epic'); +export const TOKEN_TITLE_WEIGHT = __('Weight'); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 107ced550c1..3e7feb91b27 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -93,9 +93,9 @@ export default { sortBy.sortDirection.descending === this.initialSortBy, ) .pop(); - selectedSortDirection = this.initialSortBy.endsWith('_desc') - ? SortDirection.descending - : SortDirection.ascending; + selectedSortDirection = Object.keys(selectedSortOption.sortDirection).find( + (key) => selectedSortOption.sortDirection[key] === this.initialSortBy, + ); } return { @@ -324,7 +324,9 @@ export default { class="gl-align-self-center" :checked="checkboxChecked" @input="$emit('checked-input', $event)" - /> + > + <span class="gl-sr-only">{{ __('Select all') }}</span> + </gl-form-checkbox> <gl-filtered-search ref="filteredSearchInput" v-model="filterValue" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js index a15cf220ee5..e5c8d29e09b 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -1,6 +1,9 @@ -import { isEmpty } from 'lodash'; +import { isEmpty, uniqWith, isEqual } from 'lodash'; +import AccessorUtilities from '~/lib/utils/accessor'; import { queryToObject } from '~/lib/utils/url_utility'; +import { MAX_RECENT_TOKENS_SIZE } from './constants'; + /** * Strips enclosing quotations from a string if it has one. * @@ -162,3 +165,38 @@ export function urlQueryToFilter(query = '') { return { ...memo, [filterName]: { value, operator } }; }, {}); } + +/** + * Returns array of token values from localStorage + * based on provided recentTokenValuesStorageKey + * + * @param {String} recentTokenValuesStorageKey + * @returns + */ +export function getRecentlyUsedTokenValues(recentTokenValuesStorageKey) { + let recentlyUsedTokenValues = []; + if (AccessorUtilities.isLocalStorageAccessSafe()) { + recentlyUsedTokenValues = JSON.parse(localStorage.getItem(recentTokenValuesStorageKey)) || []; + } + return recentlyUsedTokenValues; +} + +/** + * Sets provided token value to recently used array + * within localStorage for provided recentTokenValuesStorageKey + * + * @param {String} recentTokenValuesStorageKey + * @param {Object} tokenValue + */ +export function setTokenValueToRecentlyUsed(recentTokenValuesStorageKey, tokenValue) { + const recentlyUsedTokenValues = getRecentlyUsedTokenValues(recentTokenValuesStorageKey); + + recentlyUsedTokenValues.splice(0, 0, { ...tokenValue }); + + if (AccessorUtilities.isLocalStorageAccessSafe()) { + localStorage.setItem( + recentTokenValuesStorageKey, + JSON.stringify(uniqWith(recentlyUsedTokenValues, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)), + ); + } +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue new file mode 100644 index 00000000000..6ebc5431012 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -0,0 +1,167 @@ +<script> +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlDropdownSectionHeader, + GlLoadingIcon, +} from '@gitlab/ui'; + +import { DEBOUNCE_DELAY } from '../constants'; +import { getRecentlyUsedTokenValues, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlDropdownSectionHeader, + GlLoadingIcon, + }, + props: { + tokenConfig: { + type: Object, + required: true, + }, + tokenValue: { + type: Object, + required: true, + }, + tokenActive: { + type: Boolean, + required: true, + }, + tokensListLoading: { + type: Boolean, + required: true, + }, + tokenValues: { + type: Array, + required: true, + }, + fnActiveTokenValue: { + type: Function, + required: true, + }, + defaultTokenValues: { + type: Array, + required: false, + default: () => [], + }, + recentTokenValuesStorageKey: { + type: String, + required: false, + default: '', + }, + valueIdentifier: { + type: String, + required: false, + default: 'id', + }, + fnCurrentTokenValue: { + type: Function, + required: false, + default: null, + }, + }, + data() { + return { + searchKey: '', + recentTokenValues: this.recentTokenValuesStorageKey + ? getRecentlyUsedTokenValues(this.recentTokenValuesStorageKey) + : [], + loading: false, + }; + }, + computed: { + isRecentTokenValuesEnabled() { + return Boolean(this.recentTokenValuesStorageKey); + }, + recentTokenIds() { + return this.recentTokenValues.map((tokenValue) => tokenValue.id || tokenValue.name); + }, + currentTokenValue() { + if (this.fnCurrentTokenValue) { + return this.fnCurrentTokenValue(this.tokenValue.data); + } + return this.tokenValue.data.toLowerCase(); + }, + activeTokenValue() { + return this.fnActiveTokenValue(this.tokenValues, this.currentTokenValue); + }, + /** + * Return all the tokenValues when searchKey is present + * otherwise return only the tokenValues which aren't + * present in "Recently used" + */ + availableTokenValues() { + return this.searchKey + ? this.tokenValues + : this.tokenValues.filter( + (tokenValue) => !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]), + ); + }, + }, + watch: { + tokenActive: { + immediate: true, + handler(newValue) { + if (!newValue && !this.tokenValues.length) { + this.$emit('fetch-token-values', this.tokenValue.data); + } + }, + }, + }, + methods: { + handleInput({ data }) { + this.searchKey = data; + setTimeout(() => { + if (!this.tokensListLoading) this.$emit('fetch-token-values', data); + }, DEBOUNCE_DELAY); + }, + handleTokenValueSelected(activeTokenValue) { + if (this.isRecentTokenValuesEnabled) { + setTokenValueToRecentlyUsed(this.recentTokenValuesStorageKey, activeTokenValue); + } + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="tokenConfig" + v-bind="{ ...this.$parent.$props, ...this.$parent.$attrs }" + v-on="this.$parent.$listeners" + @input="handleInput" + @select="handleTokenValueSelected(activeTokenValue)" + > + <template #view-token="viewTokenProps"> + <slot name="view-token" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> + </template> + <template #view="viewTokenProps"> + <slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> + </template> + <template #suggestions> + <template v-if="defaultTokenValues.length"> + <gl-filtered-search-suggestion + v-for="token in defaultTokenValues" + :key="token.value" + :value="token.value" + > + {{ token.text }} + </gl-filtered-search-suggestion> + <gl-dropdown-divider /> + </template> + <template v-if="isRecentTokenValuesEnabled && recentTokenValues.length && !searchKey"> + <gl-dropdown-section-header>{{ __('Recently used') }}</gl-dropdown-section-header> + <slot name="token-values-list" :token-values="recentTokenValues"></slot> + <gl-dropdown-divider /> + </template> + <gl-loading-icon v-if="tokensListLoading" /> + <template v-else> + <slot name="token-values-list" :token-values="availableTokenValues"></slot> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index 98190d716c9..f2f4787d80b 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -10,7 +10,7 @@ import { debounce } from 'lodash'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; -import { DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants'; +import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; export default { @@ -33,7 +33,7 @@ export default { data() { return { emojis: this.config.initialEmojis || [], - defaultEmojis: this.config.defaultEmojis || [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY], + defaultEmojis: this.config.defaultEmojis || DEFAULT_NONE_ANY, loading: true, }; }, @@ -47,6 +47,16 @@ export default { ); }, }, + watch: { + active: { + immediate: true, + handler(newValue) { + if (!newValue && !this.emojis.length) { + this.fetchEmojiBySearchTerm(this.value.data); + } + }, + }, + }, methods: { fetchEmojiBySearchTerm(searchTerm) { this.loading = true; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue index 101c7150c55..1450807b11d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue @@ -1,15 +1,18 @@ <script> -import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { + GlDropdownDivider, + GlFilteredSearchSuggestion, + GlFilteredSearchToken, + GlLoadingIcon, +} from '@gitlab/ui'; import { debounce } from 'lodash'; - import createFlash from '~/flash'; -import { isNumeric } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; -import { DEBOUNCE_DELAY } from '../constants'; -import { stripQuotes } from '../filtered_search_utils'; +import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; export default { components: { + GlDropdownDivider, GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon, @@ -32,29 +35,16 @@ export default { }, computed: { currentValue() { - /* - * When the URL contains the epic_iid, we'd get: '123' - */ - if (isNumeric(this.value.data)) { - return parseInt(this.value.data, 10); - } - - /* - * When the token is added in current session it'd be: 'Foo::&123' - */ - const id = this.value.data.split('::&')[1]; - - if (id) { - return parseInt(id, 10); - } - - return this.value.data; + return Number(this.value.data); + }, + defaultEpics() { + return this.config.defaultEpics || DEFAULT_NONE_ANY; + }, + idProperty() { + return this.config.idProperty || 'id'; }, activeEpic() { - const currentValueIsString = typeof this.currentValue === 'string'; - return this.epics.find( - (epic) => epic[currentValueIsString ? 'title' : 'iid'] === this.currentValue, - ); + return this.epics.find((epic) => epic[this.idProperty] === this.currentValue); }, }, watch: { @@ -72,20 +62,8 @@ export default { this.loading = true; this.config .fetchEpics(searchTerm) - .then(({ data }) => { - this.epics = data; - }) - .catch(() => createFlash({ message: __('There was a problem fetching epics.') })) - .finally(() => { - this.loading = false; - }); - }, - fetchSingleEpic(iid) { - this.loading = true; - this.config - .fetchSingleEpic(iid) - .then(({ data }) => { - this.epics = [data]; + .then((response) => { + this.epics = Array.isArray(response) ? response : response.data; }) .catch(() => createFlash({ message: __('There was a problem fetching epics.') })) .finally(() => { @@ -93,17 +71,13 @@ export default { }); }, searchEpics: debounce(function debouncedSearch({ data }) { - if (isNumeric(data)) { - return this.fetchSingleEpic(data); - } - return this.fetchEpicsBySearchTerm(data); + this.fetchEpicsBySearchTerm(data); }, DEBOUNCE_DELAY), - getEpicValue(epic) { - return `${epic.title}::&${epic.iid}`; + getEpicDisplayText(epic) { + return `${epic.title}::&${epic[this.idProperty]}`; }, }, - stripQuotes, }; </script> @@ -115,17 +89,25 @@ export default { @input="searchEpics" > <template #view="{ inputValue }"> - <span>{{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}</span> + {{ activeEpic ? getEpicDisplayText(activeEpic) : inputValue }} </template> <template #suggestions> + <gl-filtered-search-suggestion + v-for="epic in defaultEpics" + :key="epic.value" + :value="epic.value" + > + {{ epic.text }} + </gl-filtered-search-suggestion> + <gl-dropdown-divider v-if="defaultEpics.length" /> <gl-loading-icon v-if="loading" /> <template v-else> <gl-filtered-search-suggestion v-for="epic in epics" - :key="epic.id" - :value="getEpicValue(epic)" + :key="epic[idProperty]" + :value="String(epic[idProperty])" > - <div>{{ epic.title }}</div> + {{ epic.title }} </gl-filtered-search-suggestion> </template> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue new file mode 100644 index 00000000000..7b6a590279a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue @@ -0,0 +1,110 @@ +<script> +import { + GlDropdownDivider, + GlFilteredSearchSuggestion, + GlFilteredSearchToken, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import { DEBOUNCE_DELAY, DEFAULT_ITERATIONS } from '../constants'; + +export default { + components: { + GlDropdownDivider, + GlFilteredSearchSuggestion, + GlFilteredSearchToken, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + iterations: this.config.initialIterations || [], + defaultIterations: this.config.defaultIterations || DEFAULT_ITERATIONS, + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data; + }, + activeIteration() { + return this.iterations.find((iteration) => iteration.title === this.currentValue); + }, + }, + watch: { + active: { + immediate: true, + handler(newValue) { + if (!newValue && !this.iterations.length) { + this.fetchIterationBySearchTerm(this.currentValue); + } + }, + }, + }, + methods: { + fetchIterationBySearchTerm(searchTerm) { + const fetchPromise = this.config.fetchPath + ? this.config.fetchIterations(this.config.fetchPath, searchTerm) + : this.config.fetchIterations(searchTerm); + + this.loading = true; + + fetchPromise + .then((response) => { + this.iterations = Array.isArray(response) ? response : response.data; + }) + .catch(() => createFlash({ message: __('There was a problem fetching iterations.') })) + .finally(() => { + this.loading = false; + }); + }, + searchIterations: debounce(function debouncedSearch({ data }) { + this.fetchIterationBySearchTerm(data); + }, DEBOUNCE_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchIterations" + > + <template #view="{ inputValue }"> + {{ activeIteration ? activeIteration.title : inputValue }} + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="iteration in defaultIterations" + :key="iteration.value" + :value="iteration.value" + > + {{ iteration.text }} + </gl-filtered-search-suggestion> + <gl-dropdown-divider v-if="defaultIterations.length" /> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="iteration in iterations" + :key="iteration.title" + :value="iteration.title" + > + {{ iteration.title }} + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue new file mode 100644 index 00000000000..72116f0e991 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue @@ -0,0 +1,58 @@ +<script> +import { GlDropdownDivider, GlFilteredSearchSuggestion, GlFilteredSearchToken } from '@gitlab/ui'; +import { DEFAULT_NONE_ANY } from '../constants'; + +export default { + baseWeights: ['0', '1', '2', '3', '4', '5'], + components: { + GlDropdownDivider, + GlFilteredSearchSuggestion, + GlFilteredSearchToken, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + weights: this.$options.baseWeights, + defaultWeights: this.config.defaultWeights || DEFAULT_NONE_ANY, + }; + }, + methods: { + updateWeights({ data }) { + const weight = parseInt(data, 10); + this.weights = Number.isNaN(weight) ? this.$options.baseWeights : [String(weight)]; + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="updateWeights" + > + <template #suggestions> + <gl-filtered-search-suggestion + v-for="weight in defaultWeights" + :key="weight.value" + :value="weight.value" + > + {{ weight.text }} + </gl-filtered-search-suggestion> + <gl-dropdown-divider v-if="defaultWeights.length" /> + <gl-filtered-search-suggestion v-for="weight of weights" :key="weight" :value="weight"> + {{ weight }} + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index be0c843ef00..ccdb47e3144 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -146,6 +146,7 @@ export default { <span v-if="dueDate" class="order-md-1"> <issue-due-date :date="dueDate" + :closed="Boolean(closedAt)" tooltip-placement="top" css-class="item-due-date gl-display-flex gl-align-items-center" /> diff --git a/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue b/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue new file mode 100644 index 00000000000..d68c4399275 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue @@ -0,0 +1,51 @@ +<script> +export default { + props: { + slotKey: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + aliveSlotsLookup: {}, + }; + }, + computed: { + aliveSlots() { + return Object.keys(this.aliveSlotsLookup); + }, + }, + watch: { + slotKey: { + handler(val) { + if (!val) { + return; + } + + this.$set(this.aliveSlotsLookup, val, true); + }, + immediate: true, + }, + }, + methods: { + isCurrentSlot(key) { + return key === this.slotKey; + }, + }, +}; +</script> + +<template> + <div> + <div + v-for="slot in aliveSlots" + v-show="isCurrentSlot(slot)" + :key="slot" + class="gl-h-full gl-w-full" + > + <slot :name="slot"></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue index 90ac20fe748..d6a20984ad1 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -34,7 +34,7 @@ export default { boundary="window" right menu-class="gl-w-full!" - data-qa-selector="apply_suggestion_button" + data-qa-selector="apply_suggestion_dropdown" @shown="$refs.commitMessage.$el.focus()" > <gl-dropdown-form class="gl-px-4! gl-m-0!"> @@ -45,7 +45,7 @@ export default { v-model="message" :placeholder="defaultCommitMessage" submit-on-enter - data-qa-selector="commit_message_textbox" + data-qa-selector="commit_message_field" @submit="onApply" /> <gl-button diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 01cf0beea3a..d343ba700ab 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -63,6 +63,9 @@ export default { '\n', ); }, + mdCollapsibleSection() { + return ['<details><summary>Click to expand</summary>', `{text}`, '</details>'].join('\n'); + }, isMac() { // Accessing properties using ?. to allow tests to use // this component without setting up window.gl.client. @@ -245,6 +248,13 @@ export default { icon="list-task" /> <toolbar-button + :tag="mdCollapsibleSection" + :prepend="true" + tag-select="Click to expand" + :button-title="__('Add a collapsible section')" + icon="details-block" + /> + <toolbar-button :tag="mdTable" :prepend="true" :button-title="__('Add a table')" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index bcd8c02e968..9c954fce322 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -70,7 +70,7 @@ export default { <template> <div class="md-suggestion"> <suggestion-diff-header - class="qa-suggestion-diff-header js-suggestion-diff-header" + class="js-suggestion-diff-header" :suggestions-count="suggestionsCount" :can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled" :is-applied="suggestion.applied" diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue index e2591362611..d05e45e90b3 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -4,6 +4,7 @@ import Api from '~/api'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import Tracking from '~/tracking'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import { initialPaginationState, defaultI18n, defaultPageSize } from './constants'; @@ -105,7 +106,7 @@ export default { unique: true, symbol: '@', token: AuthorToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, fetchPath: this.projectPath, fetchAuthors: Api.projectUsers.bind(Api), }, @@ -116,7 +117,7 @@ export default { unique: true, symbol: '@', token: AuthorToken, - operators: [{ value: '=', description: __('is'), default: 'true' }], + operators: OPERATOR_IS_ONLY, fetchPath: this.projectPath, fetchAuthors: Api.projectUsers.bind(Api), }, diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue index 4ade75e705e..b9e916bc199 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -32,7 +32,7 @@ export default { return { 'gl-border-t-transparent': !this.first && !this.selected, 'gl-border-t-gray-100': this.first && !this.selected, - 'disabled-content': this.disabled, + 'gl-opacity-5': this.disabled, 'gl-border-b-gray-100': !this.selected, 'gl-bg-blue-50 gl-border-blue-200': this.selected, }; diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue index dff3a6a8c3f..07272a5b8d6 100644 --- a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue +++ b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue @@ -55,13 +55,12 @@ export default { return !this.isAccessRequest && this.oncallSchedules.schedules?.length; }, oncallSchedules() { - let schedules = {}; try { - schedules = JSON.parse(this.modalData.oncallSchedules); + return JSON.parse(this.modalData.oncallSchedules); } catch (e) { Sentry.captureException(e); } - return schedules; + return {}; }, }, mounted() { diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue index 795b4f58ac5..1f70644eb2c 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue @@ -9,7 +9,9 @@ import { GlIcon, GlLoadingIcon, GlSkeletonLoader, + GlResizeObserverDirective, } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { isEmpty } from 'lodash'; import { __, s__ } from '~/locale'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -33,6 +35,9 @@ export default { GlSkeletonLoader, ModalCopyButton, }, + directives: { + GlResizeObserver: GlResizeObserverDirective, + }, props: { modalId: { type: String, @@ -87,6 +92,7 @@ export default { selectedArchitecture: null, showAlert: false, instructions: {}, + platformsButtonGroupVertical: false, }; }, computed: { @@ -127,6 +133,13 @@ export default { toggleAlert(state) { this.showAlert = state; }, + onPlatformsButtonResize() { + if (bp.getBreakpointSize() === 'xs') { + this.platformsButtonGroupVertical = true; + } else { + this.platformsButtonGroupVertical = false; + } + }, }, i18n: { installARunner: s__('Runners|Install a runner'), @@ -159,17 +172,23 @@ export default { <h5> {{ __('Environment') }} </h5> - <gl-button-group class="gl-mb-3"> - <gl-button - v-for="platform in platforms" - :key="platform.name" - :selected="selectedPlatform && selectedPlatform.name === platform.name" - data-testid="platform-button" - @click="selectPlatform(platform)" + <div v-gl-resize-observer="onPlatformsButtonResize"> + <gl-button-group + :vertical="platformsButtonGroupVertical" + :class="{ 'gl-w-full': platformsButtonGroupVertical }" + class="gl-mb-3" + data-testid="platform-buttons" > - {{ platform.humanReadableName }} - </gl-button> - </gl-button-group> + <gl-button + v-for="platform in platforms" + :key="platform.name" + :selected="selectedPlatform && selectedPlatform.name === platform.name" + @click="selectPlatform(platform)" + > + {{ platform.humanReadableName }} + </gl-button> + </gl-button-group> + </div> </template> <template v-if="hasArchitecureList"> <template v-if="selectedPlatform"> @@ -190,7 +209,7 @@ export default { {{ architecture.name }} </gl-dropdown-item> </gl-dropdown> - <div class="gl-display-flex gl-align-items-center gl-mb-3"> + <div class="gl-sm-display-flex gl-align-items-center gl-mb-3"> <h5>{{ $options.i18n.downloadInstallBinary }}</h5> <gl-button class="gl-ml-auto" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue index 9b28ce0d881..94cf1f84ec3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue @@ -61,6 +61,7 @@ export default { </script> <template> + <!-- eslint-disable @gitlab/vue-no-data-toggle --> <button ref="dropdownButton" :class="{ 'js-extra-options': showExtraOptions }" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue index e3704198ad0..d80b66fd9be 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue @@ -1,5 +1,5 @@ <script> -import { mapState } from 'vuex'; +import { mapGetters, mapState } from 'vuex'; import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; @@ -18,6 +18,7 @@ export default { }, computed: { ...mapState(['showDropdownContentsCreateView']), + ...mapGetters(['isDropdownVariantSidebar']), dropdownContentsView() { if (this.showDropdownContentsCreateView) { return 'dropdown-contents-create-view'; @@ -25,11 +26,8 @@ export default { return 'dropdown-contents-labels-view'; }, directionStyle() { - if (this.renderOnTop) { - return { bottom: '100%' }; - } - - return {}; + const bottom = this.isDropdownVariantSidebar ? '3rem' : '2rem'; + return this.renderOnTop ? { bottom } : {}; }, }, }; @@ -37,7 +35,7 @@ export default { <template> <div - class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute" + class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute" data-qa-selector="labels_dropdown_content" :style="directionStyle" > diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index 6065b6c160c..86788a84260 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -83,12 +83,13 @@ export default { const highlightedLabel = this.$refs.labelsListContainer.querySelector('.is-focused'); if (highlightedLabel) { - const rect = highlightedLabel.getBoundingClientRect(); - if (rect.bottom > this.$refs.labelsListContainer.clientHeight) { - highlightedLabel.scrollIntoView(false); - } - if (rect.top < 0) { - highlightedLabel.scrollIntoView(); + const container = this.$refs.labelsListContainer.getBoundingClientRect(); + const label = highlightedLabel.getBoundingClientRect(); + + if (label.bottom > container.bottom) { + this.$refs.labelsListContainer.scrollTop += label.bottom - container.bottom; + } else if (label.top < container.top) { + this.$refs.labelsListContainer.scrollTop -= container.top - label.top; } } }, @@ -177,7 +178,7 @@ export default { class="labels-fetch-loading gl-align-items-center w-100 h-100" size="md" /> - <ul v-else class="list-unstyled mb-0"> + <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word"> <label-item v-for="(label, index) in visibleLabels" :key="label.id" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue index e431fd000a6..e8fdf4bb0c2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue @@ -22,7 +22,7 @@ export default { const { label, highlight, isLabelSet } = props; const labelColorBox = h('span', { - class: 'dropdown-label-box', + class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3', style: { backgroundColor: label.color, }, @@ -33,7 +33,7 @@ export default { const checkedIcon = h(GlIcon, { class: { - 'mr-2 align-self-center': true, + 'gl-mr-3 gl-flex-shrink-0': true, hidden: !isLabelSet, }, props: { @@ -43,7 +43,7 @@ export default { const noIcon = h('span', { class: { - 'mr-3 pr-2': true, + 'gl-mr-5 gl-pr-3': true, hidden: isLabelSet, }, attrs: { @@ -56,7 +56,7 @@ export default { const labelLink = h( GlLink, { - class: 'd-flex align-items-baseline text-break-word label-item', + class: 'gl-display-flex gl-align-items-center label-item gl-text-black-normal', on: { click: () => { listeners.clickLabel(label); @@ -70,8 +70,8 @@ export default { 'li', { class: { - 'd-block': true, - 'text-left': true, + 'gl-display-block': true, + 'gl-text-left': true, 'is-focused': highlight, }, }, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index f547433f322..a4462895f6a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -268,7 +268,7 @@ export default { this.$emit('toggleCollapse'); }, setContentIsOnViewport(showDropdownContents) { - if (!this.isDropdownVariantEmbedded || !showDropdownContents) { + if (!showDropdownContents) { this.contentIsOnViewport = true; return; @@ -276,8 +276,7 @@ export default { this.$nextTick(() => { if (this.$refs.dropdownContents) { - const offset = { top: 100 }; - this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el, offset); + this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el); } }); }, @@ -313,6 +312,7 @@ export default { <dropdown-contents v-show="dropdownButtonVisible && showDropdownContents" ref="dropdownContents" + :render-on-top="!contentIsOnViewport" /> </template> <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded"> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql new file mode 100644 index 00000000000..93b9833bb7d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql @@ -0,0 +1,18 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query issueAssignees($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: issue(iid: $iid) { + __typename + id + assignees { + nodes { + ...User + ...UserAvailability + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql index 3885127fa8e..48787305459 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql @@ -13,12 +13,6 @@ query issueParticipants($fullPath: ID!, $iid: String!) { ...UserAvailability } } - assignees { - nodes { - ...User - ...UserAvailability - } - } } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql new file mode 100644 index 00000000000..a2990d7171b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql @@ -0,0 +1,14 @@ +#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql" + +query timeTrackingReport($id: IssueID!) { + issuable: issue(id: $id) { + __typename + id + title + timelogs { + nodes { + ...TimelogFragment + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql new file mode 100644 index 00000000000..53f7381760e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql @@ -0,0 +1,16 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query getMrAssignees($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + issuable: mergeRequest(iid: $iid) { + id + assignees { + nodes { + ...User + ...UserAvailability + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql index 63482873b69..6adbd4098f2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql @@ -11,12 +11,6 @@ query getMrParticipants($fullPath: ID!, $iid: String!) { ...UserAvailability } } - assignees { - nodes { - ...User - ...UserAvailability - } - } } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql new file mode 100644 index 00000000000..753f1b345e3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql @@ -0,0 +1,14 @@ +#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql" + +query timeTrackingReport($id: MergeRequestID!) { + issuable: mergeRequest(id: $id) { + __typename + id + title + timelogs { + nodes { + ...TimelogFragment + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql index 3f40c0368d7..24de5ea4fe3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql @@ -13,12 +13,6 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP ...UserAvailability } } - participants { - nodes { - ...User - ...UserAvailability - } - } } } } diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue index 4447a87777a..66088b33c99 100644 --- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -15,7 +15,7 @@ export default { mixins: [timeagoMixin], props: { time: { - type: String, + type: [String, Number], required: true, }, tooltipPlacement: { diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 11f484b2cdf..deac24d2270 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -20,7 +20,7 @@ export default { }, props: { target: { - type: HTMLAnchorElement, + type: HTMLElement, required: true, }, user: { @@ -79,7 +79,7 @@ export default { <div class="gl-text-gray-500"> <div v-if="user.bio" class="gl-display-flex gl-mb-2"> <gl-icon name="profile" class="gl-text-gray-400 gl-flex-shrink-0" /> - <span ref="bio" class="gl-ml-2" v-html="user.bioHtml"></span> + <span ref="bio" class="gl-ml-2 gl-overflow-hidden" v-html="user.bioHtml"></span> </div> <div v-if="user.workInformation" class="gl-display-flex gl-mb-2"> <gl-icon name="work" class="gl-text-gray-400 gl-flex-shrink-0" /> diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue new file mode 100644 index 00000000000..3116d2fbf32 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -0,0 +1,302 @@ +<script> +import { + GlDropdown, + GlDropdownForm, + GlDropdownDivider, + GlDropdownItem, + GlSearchBoxByType, + GlLoadingIcon, +} from '@gitlab/ui'; +import searchUsers from '~/graphql_shared/queries/users_search.query.graphql'; +import { __ } from '~/locale'; +import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; +import { ASSIGNEES_DEBOUNCE_DELAY, participantsQueries } from '~/sidebar/constants'; + +export default { + i18n: { + unassigned: __('Unassigned'), + }, + components: { + GlDropdownForm, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlSearchBoxByType, + SidebarParticipant, + GlLoadingIcon, + }, + props: { + headerText: { + type: String, + required: true, + }, + text: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + iid: { + type: String, + required: true, + }, + value: { + type: Array, + required: true, + }, + allowMultipleAssignees: { + type: Boolean, + required: false, + default: false, + }, + currentUser: { + type: Object, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + data() { + return { + search: '', + participants: [], + searchUsers: [], + isSearching: false, + }; + }, + apollo: { + participants: { + query() { + return participantsQueries[this.issuableType].query; + }, + variables() { + return { + iid: this.iid, + fullPath: this.fullPath, + }; + }, + update(data) { + return data.workspace?.issuable?.participants.nodes; + }, + error() { + this.$emit('error'); + }, + }, + searchUsers: { + // TODO Remove error policy + // https://gitlab.com/gitlab-org/gitlab/-/issues/329750 + errorPolicy: 'all', + query: searchUsers, + variables() { + return { + fullPath: this.fullPath, + search: this.search, + first: 20, + }; + }, + update(data) { + // TODO Remove null filter (BE fix required) + // https://gitlab.com/gitlab-org/gitlab/-/issues/329750 + return data.workspace?.users?.nodes.filter((x) => x).map(({ user }) => user) || []; + }, + debounce: ASSIGNEES_DEBOUNCE_DELAY, + error({ graphQLErrors }) { + // TODO This error suppression is temporary (BE fix required) + // https://gitlab.com/gitlab-org/gitlab/-/issues/329750 + const isNullError = ({ message }) => { + return message === 'Cannot return null for non-nullable field GroupMember.user'; + }; + + if (graphQLErrors?.length > 0 && graphQLErrors.every(isNullError)) { + // only null-related errors exist, suppress them. + // eslint-disable-next-line no-console + console.error( + "Suppressing the error 'Cannot return null for non-nullable field GroupMember.user'. Please see https://gitlab.com/gitlab-org/gitlab/-/issues/329750", + ); + this.isSearching = false; + return; + } + + this.$emit('error'); + this.isSearching = false; + }, + result() { + this.isSearching = false; + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading; + }, + users() { + if (!this.participants) { + return []; + } + + const filteredParticipants = this.participants.filter( + (user) => user.name.includes(this.search) || user.username.includes(this.search), + ); + + // TODO this de-duplication is temporary (BE fix required) + // https://gitlab.com/gitlab-org/gitlab/-/issues/327822 + const mergedSearchResults = filteredParticipants + .concat(this.searchUsers) + .reduce( + (acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]), + [], + ); + + return this.moveCurrentUserToStart(mergedSearchResults); + }, + isSearchEmpty() { + return this.search === ''; + }, + shouldShowParticipants() { + return this.isSearchEmpty || this.isSearching; + }, + isCurrentUserInList() { + const isCurrentUser = (user) => user.username === this.currentUser.username; + return this.users.some(isCurrentUser); + }, + noUsersFound() { + return !this.isSearchEmpty && this.users.length === 0; + }, + showCurrentUser() { + return this.currentUser.username && !this.isCurrentUserInList && this.isSearchEmpty; + }, + selectedFiltered() { + if (this.shouldShowParticipants) { + return this.moveCurrentUserToStart(this.value); + } + + const foundUsernames = this.users.map(({ username }) => username); + const filtered = this.value.filter(({ username }) => foundUsernames.includes(username)); + return this.moveCurrentUserToStart(filtered); + }, + selectedUserNames() { + return this.value.map(({ username }) => username); + }, + unselectedFiltered() { + return this.users?.filter(({ username }) => !this.selectedUserNames.includes(username)) || []; + }, + selectedIsEmpty() { + return this.selectedFiltered.length === 0; + }, + }, + watch: { + // We need to add this watcher to track the moment when user is alredy typing + // but query is still not started due to debounce + search(newVal) { + if (newVal) { + this.isSearching = true; + } + }, + }, + methods: { + selectAssignee(user) { + let selected = [...this.value]; + if (!this.allowMultipleAssignees) { + selected = [user]; + } else { + selected.push(user); + } + this.$emit('input', selected); + }, + unselect(name) { + const selected = this.value.filter((user) => user.username !== name); + this.$emit('input', selected); + }, + focusSearch() { + this.$refs.search.focusInput(); + }, + showDivider(list) { + return list.length > 0 && this.isSearchEmpty; + }, + moveCurrentUserToStart(users) { + if (!users) { + return []; + } + const usersCopy = [...users]; + const currentUser = usersCopy.find((user) => user.username === this.currentUser.username); + + if (currentUser) { + const index = usersCopy.indexOf(currentUser); + usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]); + } + + return usersCopy; + }, + }, +}; +</script> + +<template> + <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')"> + <template #header> + <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p> + <gl-dropdown-divider /> + <gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" /> + </template> + <gl-dropdown-form class="gl-relative gl-min-h-7"> + <gl-loading-icon + v-if="isLoading" + data-testid="loading-participants" + size="md" + class="gl-absolute gl-left-0 gl-top-0 gl-right-0" + /> + <template v-else> + <template v-if="shouldShowParticipants"> + <gl-dropdown-item + v-if="isSearchEmpty" + :is-checked="selectedIsEmpty" + :is-check-centered="true" + data-testid="unassign" + @click="$emit('input', [])" + > + <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{ + $options.i18n.unassigned + }}</span></gl-dropdown-item + > + </template> + <gl-dropdown-divider v-if="showDivider(selectedFiltered)" /> + <gl-dropdown-item + v-for="item in selectedFiltered" + :key="item.id" + is-checked + is-check-centered + data-testid="selected-participant" + @click.stop="unselect(item.username)" + > + <sidebar-participant :user="item" /> + </gl-dropdown-item> + <template v-if="showCurrentUser"> + <gl-dropdown-divider /> + <gl-dropdown-item data-testid="current-user" @click.stop="selectAssignee(currentUser)"> + <sidebar-participant :user="currentUser" class="gl-pl-6!" /> + </gl-dropdown-item> + </template> + <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> + <gl-dropdown-item + v-for="unselectedUser in unselectedFiltered" + :key="unselectedUser.id" + data-testid="unselected-participant" + @click="selectAssignee(unselectedUser)" + > + <sidebar-participant :user="unselectedUser" class="gl-pl-6!" /> + </gl-dropdown-item> + <gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!"> + {{ __('No matching results') }} + </gl-dropdown-item> + </template> + </gl-dropdown-form> + <template #footer> + <slot name="footer"></slot> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue new file mode 100644 index 00000000000..eff39e2fb89 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue @@ -0,0 +1,21 @@ +<script> +export default { + provide() { + return { + // We can't use this.vuexModule due to bug in vue-apollo when + // provide is called in beforeCreate + // See https://github.com/vuejs/vue-apollo/pull/1153 for details + vuexModule: this.$options.propsData.vuexModule, + }; + }, + props: { + vuexModule: { + type: String, + required: true, + }, + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js index 176954891e9..692f2769b88 100644 --- a/app/assets/javascripts/vue_shared/directives/validation.js +++ b/app/assets/javascripts/vue_shared/directives/validation.js @@ -33,6 +33,10 @@ const focusFirstInvalidInput = (e) => { } }; +const getInputElement = (el) => { + return el.querySelector('input') || el; +}; + const isEveryFieldValid = (form) => Object.values(form.fields).every(({ state }) => state === true); const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = false }) => { @@ -91,8 +95,9 @@ export default function initValidation(customFeedbackMap = {}) { const elDataMap = new WeakMap(); return { - inserted(el, binding, { context }) { + inserted(element, binding, { context }) { const { arg: showGlobalValidation } = binding; + const el = getInputElement(element); const { form: formEl } = el; const validate = createValidator(context, feedbackMap); @@ -121,7 +126,8 @@ export default function initValidation(customFeedbackMap = {}) { validate({ el, reportInvalidInput: showGlobalValidation }); }, - update(el, binding) { + update(element, binding) { + const el = getInputElement(element); const { arg: showGlobalValidation } = binding; const { validate, isTouched, isBlurred } = elDataMap.get(el); const showValidationFeedback = showGlobalValidation || (isTouched && isBlurred); @@ -130,3 +136,59 @@ export default function initValidation(customFeedbackMap = {}) { }, }; } + +/** + * This is a helper that initialize the form fields structure to be used in initForm + * @param {*} fieldValues + * @returns formObject + */ +const initFormField = ({ value, required = true, skipValidation = false }) => ({ + value, + required, + state: skipValidation ? true : null, + feedback: null, +}); + +/** + * This is a helper that initialize the form structure that is compliant to be used with the validation directive + * + * @example + * const form initForm = initForm({ + * fields: { + * name: { + * value: 'lorem' + * }, + * description: { + * value: 'ipsum', + * required: false, + * skipValidation: true + * } + * } + * }) + * + * @example + * const form initForm = initForm({ + * state: true, // to override + * foo: { // something custom + * bar: 'lorem' + * }, + * fields: {...} + * }) + * + * @param {*} formObject + * @returns form + */ +export const initForm = ({ fields = {}, ...rest } = {}) => { + const initFields = Object.fromEntries( + Object.entries(fields).map(([fieldName, fieldValues]) => { + return [fieldName, initFormField(fieldValues)]; + }), + ); + + return { + state: false, + showValidation: false, + ...rest, + fields: initFields, + }; +}; diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js index af14c6d9486..45452f2ea35 100644 --- a/app/assets/javascripts/vue_shared/mixins/timeago.js +++ b/app/assets/javascripts/vue_shared/mixins/timeago.js @@ -14,5 +14,25 @@ export default { tooltipTitle(time) { return formatDate(time); }, + + durationTimeFormatted(duration) { + const date = new Date(duration * 1000); + + let hh = date.getUTCHours(); + let mm = date.getUTCMinutes(); + let ss = date.getSeconds(); + + if (hh < 10) { + hh = `0${hh}`; + } + if (mm < 10) { + mm = `0${mm}`; + } + if (ss < 10) { + ss = `0${ss}`; + } + + return `${hh}:${mm}:${ss}`; + }, }, }; diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue index d2fc2c66924..d2fc2c66924 100644 --- a/app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue new file mode 100644 index 00000000000..e9983af5401 --- /dev/null +++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue @@ -0,0 +1,71 @@ +<script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import Vue from 'vue'; +import Tracking from '~/tracking'; + +export default { + directives: { + SafeHtml, + }, + props: { + title: { + type: String, + required: true, + }, + panels: { + type: Array, + required: true, + }, + experiment: { + type: String, + required: false, + default: null, + }, + }, + created() { + const trackingMixin = Tracking.mixin({ ...gon.tracking_data, experiment: this.experiment }); + const trackingInstance = new Vue({ + ...trackingMixin, + render() { + return null; + }, + }); + this.track = trackingInstance.track; + }, +}; +</script> +<template> + <div class="container"> + <h2 class="gl-my-7 gl-font-size-h1 gl-text-center"> + {{ title }} + </h2> + <div> + <div + v-for="panel in panels" + :key="panel.name" + class="new-namespace-panel-wrapper gl-display-inline-block gl-px-3 gl-mb-5" + > + <a + :href="`#${panel.name}`" + :data-qa-selector="`${panel.name}_link`" + class="new-namespace-panel gl-display-flex gl-flex-shrink-0 gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-center gl-rounded-base gl-border-gray-100 gl-border-solid gl-border-1 gl-w-full gl-py-6 gl-px-8 gl-hover-text-decoration-none!" + @click="track('click_tab', { label: panel.name })" + > + <div + v-safe-html="panel.illustration" + class="new-namespace-panel-illustration gl-text-white gl-display-flex gl-flex-shrink-0 gl-justify-content-center" + ></div> + <div class="gl-pl-4"> + <h3 class="gl-font-size-h2 gl-reset-color"> + {{ panel.title }} + </h3> + <p class="gl-text-gray-900"> + {{ panel.description }} + </p> + </div> + </a> + </div> + </div> + <slot name="footer"></slot> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue new file mode 100644 index 00000000000..54313297b14 --- /dev/null +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -0,0 +1,135 @@ +<script> +import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; + +import LegacyContainer from './components/legacy_container.vue'; +import WelcomePage from './components/welcome.vue'; + +export default { + components: { + GlBreadcrumb, + GlIcon, + WelcomePage, + LegacyContainer, + }, + directives: { + SafeHtml, + }, + props: { + title: { + type: String, + required: true, + }, + initialBreadcrumb: { + type: String, + required: true, + }, + panels: { + type: Array, + required: true, + }, + jumpToLastPersistedPanel: { + type: Boolean, + required: false, + default: false, + }, + persistenceKey: { + type: String, + required: true, + }, + experiment: { + type: String, + required: false, + default: null, + }, + }, + + data() { + return { + activePanelName: null, + }; + }, + + computed: { + activePanel() { + return this.panels.find((p) => p.name === this.activePanelName); + }, + + details() { + return this.activePanel.details || this.activePanel.description; + }, + + hasTextDetails() { + return typeof this.details === 'string'; + }, + + breadcrumbs() { + if (!this.activePanel) { + return null; + } + + return [ + { text: this.initialBreadcrumb, href: '#' }, + { text: this.activePanel.title, href: `#${this.activePanel.name}` }, + ]; + }, + }, + + created() { + this.handleLocationHashChange(); + + if (this.jumpToLastPersistedPanel) { + this.activePanelName = localStorage.getItem(this.persistenceKey) || this.panels[0].name; + } + + window.addEventListener('hashchange', () => { + this.handleLocationHashChange(); + this.$emit('panel-change'); + }); + + this.$root.$on('clicked::link', (e) => { + window.location = e.target.href; + }); + }, + + methods: { + handleLocationHashChange() { + this.activePanelName = window.location.hash.substring(1) || null; + if (this.activePanelName) { + localStorage.setItem(this.persistenceKey, this.activePanelName); + } + }, + }, +}; +</script> + +<template> + <welcome-page + v-if="activePanelName === null" + :panels="panels" + :title="title" + :experiment="experiment" + > + <template #footer> + <slot name="welcome-footer"> </slot> + </template> + </welcome-page> + <div v-else class="row"> + <div class="col-lg-3"> + <div v-safe-html="activePanel.illustration" class="gl-text-white"></div> + <h4>{{ activePanel.title }}</h4> + + <p v-if="hasTextDetails">{{ details }}</p> + <component :is="details" v-else /> + + <slot name="extra-description"></slot> + </div> + <div class="col-lg-9"> + <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs"> + <template #separator> + <gl-icon name="chevron-right" :size="8" /> + </template> + </gl-breadcrumb> + <legacy-container :key="activePanel.name" class="gl-mt-3" :selector="activePanel.selector" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue new file mode 100644 index 00000000000..12e5f634a08 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -0,0 +1,83 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { sprintf, s__ } from '~/locale'; +import apolloProvider from '../provider'; + +export default { + apolloProvider, + components: { + GlButton, + }, + inject: ['projectPath'], + props: { + feature: { + type: Object, + required: true, + }, + variant: { + type: String, + required: false, + default: 'success', + }, + category: { + type: String, + required: false, + default: 'secondary', + }, + }, + data() { + return { + isLoading: false, + }; + }, + computed: { + featureSettings() { + return featureToMutationMap[this.feature.type]; + }, + }, + methods: { + async mutate() { + this.isLoading = true; + try { + const mutation = this.featureSettings; + const { data } = await this.$apollo.mutate(mutation.getMutationPayload(this.projectPath)); + const { errors, successPath } = data[mutation.mutationId]; + + if (errors.length > 0) { + throw new Error(errors[0]); + } + + if (!successPath) { + throw new Error( + sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }), + ); + } + + redirectTo(successPath); + } catch (e) { + this.$emit('error', e.message); + this.isLoading = false; + } + }, + }, + i18n: { + buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'), + noSuccessPathError: s__( + 'SecurityConfiguration|%{featureName} merge request creation mutation failed', + ), + }, +}; +</script> + +<template> + <gl-button + v-if="!feature.configured" + :loading="isLoading" + :variant="variant" + :category="category" + @click="mutate" + >{{ $options.i18n.buttonLabel }}</gl-button + > +</template> diff --git a/app/assets/javascripts/vue_shared/security_configuration/provider.js b/app/assets/javascripts/vue_shared/security_configuration/provider.js new file mode 100644 index 00000000000..ef96b443da8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_configuration/provider.js @@ -0,0 +1,9 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export default new VueApollo({ + defaultClient: createDefaultClient(), +}); diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql index 4ce13827da2..4ce13827da2 100644 --- a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql new file mode 100644 index 00000000000..c7e9fa16418 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql @@ -0,0 +1,18 @@ +query getCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) { + project(fullPath: $projectPath) { + pipeline(iid: $iid) { + id + jobs(securityReportTypes: $reportTypes) { + nodes { + name + artifacts { + nodes { + downloadPath + fileType + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue index 1151cffa76f..b7f283b8fd9 100644 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -13,10 +13,10 @@ import { REPORT_TYPE_SECRET_DETECTION, reportTypeToSecurityReportTypeEnum, } from './constants'; -import securityReportDownloadPathsQuery from './queries/security_report_download_paths.query.graphql'; +import securityReportMergeRequestDownloadPathsQuery from './queries/security_report_merge_request_download_paths.query.graphql'; import store from './store'; import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants'; -import { extractSecurityReportArtifacts } from './utils'; +import { extractSecurityReportArtifactsFromMergeRequest } from './utils'; export default { store, @@ -86,7 +86,7 @@ export default { }, apollo: { reportArtifacts: { - query: securityReportDownloadPathsQuery, + query: securityReportMergeRequestDownloadPathsQuery, variables() { return { projectPath: this.targetProjectFullPath, @@ -97,7 +97,7 @@ export default { }; }, update(data) { - return extractSecurityReportArtifacts(this.$options.reportTypes, data); + return extractSecurityReportArtifactsFromMergeRequest(this.$options.reportTypes, data); }, error(error) { this.showError(error); diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js index ad819bf7081..c3f24a7e52f 100644 --- a/app/assets/javascripts/vue_shared/security_reports/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/utils.js @@ -14,9 +14,7 @@ const addReportTypeIfExists = (acc, reportTypes, reportType, getName, downloadPa } }; -export const extractSecurityReportArtifacts = (reportTypes, data) => { - const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? []; - +const extractSecurityReportArtifacts = (reportTypes, jobs) => { return jobs.reduce((acc, job) => { const artifacts = job.artifacts?.nodes ?? []; @@ -41,3 +39,13 @@ export const extractSecurityReportArtifacts = (reportTypes, data) => { return acc; }, []); }; + +export const extractSecurityReportArtifactsFromPipeline = (reportTypes, data) => { + const jobs = data.project?.pipeline?.jobs?.nodes ?? []; + return extractSecurityReportArtifacts(reportTypes, jobs); +}; + +export const extractSecurityReportArtifactsFromMergeRequest = (reportTypes, data) => { + const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? []; + return extractSecurityReportArtifacts(reportTypes, jobs); +}; diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index 4a387edbe3f..4ee586527b5 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -30,7 +30,7 @@ export default { }, mounted() { this.openDrawer(this.versionDigest); - this.fetchItems(); + this.fetchFreshItems(); const body = document.querySelector('body'); const namespaceId = body.getAttribute('data-namespace-id'); @@ -42,13 +42,18 @@ export default { bottomReached() { const page = this.pageInfo.nextPage; if (page) { - this.fetchItems({ page }); + this.fetchFreshItems(page); } }, handleResize() { const height = getDrawerBodyHeight(this.$refs.drawer.$el); this.setDrawerBodyHeight(height); }, + fetchFreshItems(page) { + const { versionDigest } = this; + + this.fetchItems({ page, versionDigest }); + }, }, }; </script> @@ -58,7 +63,7 @@ export default { <gl-drawer ref="drawer" v-gl-resize-observer="handleResize" - class="whats-new-drawer" + class="whats-new-drawer gl-reset-line-height" :z-index="700" :open="open" @close="closeDrawer" @@ -83,6 +88,6 @@ export default { <skeleton-loader /> </div> </gl-drawer> - <div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div> + <div v-if="open" class="whats-new-modal-backdrop modal-backdrop" @click="closeDrawer"></div> </div> </template> diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue index f6f7618b0d8..5444e77a4d2 100644 --- a/app/assets/javascripts/whats_new/components/feature.vue +++ b/app/assets/javascripts/whats_new/components/feature.vue @@ -1,11 +1,13 @@ <script> -import { GlBadge, GlIcon, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlBadge, GlIcon, GlLink, GlSafeHtmlDirective, GlButton } from '@gitlab/ui'; +import { dateInWords, isValidDate } from '~/lib/utils/datetime_utility'; export default { components: { GlBadge, GlIcon, GlLink, + GlButton, }, directives: { SafeHtml: GlSafeHtmlDirective, @@ -16,52 +18,68 @@ export default { required: true, }, }, + computed: { + releaseDate() { + const { published_at } = this.feature; + const date = new Date(published_at); + + if (!isValidDate(date) || date.getTime() === 0) { + return ''; + } + + return dateInWords(date); + }, + }, }; </script> <template> - <div class="gl-pb-7 gl-pt-5 gl-px-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> + <div class="gl-py-6 gl-px-6 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> + <gl-link + :href="feature.url" + target="_blank" + class="gl-display-block" + data-track-event="click_whats_new_item" + :data-track-label="feature.title" + :data-track-property="feature.url" + > + <div + class="whats-new-item-image gl-bg-size-cover" + :style="`background-image: url(${feature.image_url});`" + > + <span class="gl-sr-only">{{ feature.title }}</span> + </div> + </gl-link> <gl-link :href="feature.url" target="_blank" - class="whats-new-item-title-link" + class="whats-new-item-title-link gl-display-block gl-mt-4 gl-mb-1" data-track-event="click_whats_new_item" :data-track-label="feature.title" :data-track-property="feature.url" > - <h5 class="gl-font-lg" data-test-id="feature-title">{{ feature.title }}</h5> + <h5 class="gl-font-lg gl-my-0" data-test-id="feature-title">{{ feature.title }}</h5> </gl-link> + <div v-if="releaseDate" class="gl-mb-3" data-testid="release-date">{{ releaseDate }}</div> <div v-if="feature.packages" class="gl-mb-3"> <gl-badge v-for="packageName in feature.packages" :key="packageName" - size="sm" + size="md" class="whats-new-item-badge gl-mr-2" > <gl-icon name="license" />{{ packageName }} </gl-badge> </div> - <gl-link - :href="feature.url" - target="_blank" - data-track-event="click_whats_new_item" - :data-track-label="feature.title" - :data-track-property="feature.url" - > - <img - :alt="feature.title" - :src="feature.image_url" - class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image" - /> - </gl-link> - <div v-safe-html="feature.body" class="gl-pt-3"></div> - <gl-link + <div v-safe-html="feature.body" class="gl-pt-3 gl-line-height-20"></div> + <gl-button :href="feature.url" target="_blank" data-track-event="click_whats_new_item" :data-track-label="feature.title" :data-track-property="feature.url" - >{{ __('Learn more') }}</gl-link > + {{ __('Learn more') }} <gl-icon name="arrow-right" /> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js index 1dc92ea2606..f209f145884 100644 --- a/app/assets/javascripts/whats_new/store/actions.js +++ b/app/assets/javascripts/whats_new/store/actions.js @@ -14,17 +14,19 @@ export default { localStorage.setItem(STORAGE_KEY, versionDigest); } }, - fetchItems({ commit, state }, { page } = { page: null }) { + fetchItems({ commit, state }, { page, versionDigest } = { page: null, versionDigest: null }) { if (state.fetching) { return false; } commit(types.SET_FETCHING, true); + const v = versionDigest; return axios .get('/-/whats_new', { params: { page, + v, }, }) .then(({ data, headers }) => { diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index a5cfc8d12b0..c4f292dd05d 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -105,10 +105,6 @@ hr { } } -kbd { - display: inline-block; -} - code { padding: 2px 4px; color: $code-color; diff --git a/app/assets/stylesheets/components/feature_highlight.scss b/app/assets/stylesheets/components/feature_highlight.scss index 08706951967..54123e74675 100644 --- a/app/assets/stylesheets/components/feature_highlight.scss +++ b/app/assets/stylesheets/components/feature_highlight.scss @@ -7,3 +7,25 @@ padding: 0.25rem; } } + +.gl-order-1 { + order: 1; +} + +.gl-sm-order-init { + @media (min-width: $breakpoint-sm) { + order: initial; + } +} + +.gl-xs-ml-3 { + @media (max-width: $breakpoint-sm) { + @include gl-ml-3; + } +} + +.gl-sm-mr-3 { + @media (min-width: $breakpoint-sm) { + @include gl-mr-3; + } +} diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss index 3e9060e869b..7af97505749 100644 --- a/app/assets/stylesheets/components/whats_new.scss +++ b/app/assets/stylesheets/components/whats_new.scss @@ -2,6 +2,7 @@ margin-top: $header-height; @include gl-shadow-none; overflow-y: hidden; + width: 500px; .gl-infinite-scroll-legend { @include gl-display-none; @@ -54,18 +55,9 @@ .whats-new-item-image { border-color: $gray-50; + height: 250px; } .whats-new-modal-backdrop { z-index: 699; } - -.whats-new-notification-count { - @include gl-bg-gray-900; - @include gl-font-sm; - @include gl-line-height-normal; - @include gl-text-white; - @include gl-vertical-align-top; - border-radius: 20px; - padding: 3px 10px; -} diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 1fe94a796f5..cde5ad24fa5 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -22,6 +22,7 @@ @import 'framework/flash'; @import 'framework/forms'; @import 'framework/gfm'; +@import 'framework/kbd'; @import 'framework/header'; @import 'framework/highlight'; @import 'framework/issue_box'; @@ -45,7 +46,6 @@ @import 'framework/toggle'; @import 'framework/typography'; @import 'framework/zen'; -@import 'framework/blank'; @import 'framework/wells'; @import 'framework/page_header'; @import 'framework/page_title'; diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss deleted file mode 100644 index 0ada5fabde9..00000000000 --- a/app/assets/stylesheets/framework/blank.scss +++ /dev/null @@ -1,136 +0,0 @@ -.blank-state-parent-container { - .section-container { - padding: 10px; - } - - .section-body { - width: 100%; - height: 100%; - padding-bottom: 25px; - border-radius: $border-radius-default; - } -} - -.blank-state-row { - display: flex; - flex-wrap: wrap; - justify-content: space-between; -} - -.blank-state-welcome { - text-align: center; - padding: $gl-padding 0 ($gl-padding * 2); - - .blank-state-welcome-title { - font-size: 24px; - } - - .blank-state-text { - margin-bottom: 0; - } -} - -.blank-state-link { - color: $gl-text-color; - margin-bottom: 15px; - - &:hover { - background-color: $gray-light; - text-decoration: none; - color: $gl-text-color; - } -} - -.blank-state-center { - padding-top: 20px; - padding-bottom: 20px; - text-align: center; -} - -.blank-state { - display: flex; - align-items: center; - padding: 20px 50px; - border: 1px solid $border-color; - border-radius: $border-radius-default; - min-height: 240px; - margin-bottom: $gl-padding; - width: calc(50% - #{$gl-padding-8}); - - @include media-breakpoint-down(sm) { - width: 100%; - flex-direction: column; - justify-content: center; - padding: 50px 20px; - - .column-small & { - width: 100%; - } - - } -} - -.blank-state, -.blank-state-center { - .blank-state-icon { - svg { - display: block; - margin: auto; - } - } - - .blank-state-title { - margin-top: 0; - font-size: 18px; - } - - .blank-state-body { - @include media-breakpoint-down(sm) { - text-align: center; - margin-top: 20px; - } - - @include media-breakpoint-up(sm) { - padding-left: 20px; - } - } -} - -@include media-breakpoint-up(lg) { - .column-large { - flex: 2; - } - - .column-small { - flex: 1; - margin-bottom: 15px; - - .blank-state { - max-width: 400px; - flex-wrap: wrap; - margin-left: 15px; - } - - .blank-state-icon { - margin-bottom: 30px; - } - } -} - -.experiment-new-project-page-blank-state { - @include media-breakpoint-down(md) { - flex-direction: column; - justify-content: center; - text-align: center; - } - - .blank-state-icon { - min-width: 215px; - } -} - -$experiment-new-project-indigo-700: #41419f; - -.experiment-new-project-page-blank-state-title { - color: $experiment-new-project-indigo-700; -} diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index d1fa1187703..603d28a8395 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -337,7 +337,7 @@ .btn-loading { &:not(.disabled) { - .spinner { + .gl-spinner { display: none; } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 652ffd79ab3..a7ce19ffc69 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -93,6 +93,9 @@ .tab-content { overflow: visible; + @include media-breakpoint-down(sm) { + isolation: isolate; + } } pre { @@ -266,12 +269,6 @@ img.emoji { height: 220px; } -.description-block { - @extend .light-well; - @extend .light; - margin-bottom: 10px; -} - .footer-links { margin-bottom: 20px; diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss index c5467c304ec..14d1a0663d0 100644 --- a/app/assets/stylesheets/framework/contextual_sidebar.scss +++ b/app/assets/stylesheets/framework/contextual_sidebar.scss @@ -429,30 +429,6 @@ display: none; } -.toggle-mobile-nav { - display: none; - background-color: transparent; - border: 0; - padding: 6px 16px; - margin: 0 0 0 -15px; - height: 46px; - color: $gl-text-color; - - @include media-breakpoint-down(sm) { - display: flex; - align-items: center; - - i { - font-size: 18px; - } - - + .breadcrumbs-links { - padding-left: $gl-padding; - border-left: 1px solid $gl-text-color-quaternary; - } - } -} - @include media-breakpoint-down(sm) { .close-nav-button { display: flex; diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index bc7a31c112f..a07e0b48cff 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -670,10 +670,6 @@ table.code { float: right; } -.files-changed { - border-bottom: 0; -} - .merge-request-details .file-content.image_file img { max-height: 50vh; } @@ -733,7 +729,7 @@ table.code { } .files { - .diff-file:last-child { + .diff-file:not(.is-virtual-scrolling):last-child { margin-bottom: 0; } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ff42cd836da..894eddbe1a7 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -147,7 +147,7 @@ position: absolute; } - .spinner { + .gl-spinner { position: absolute; top: 9px; right: 8px; @@ -266,15 +266,6 @@ } } - .shortcut-mappings { - display: none; - } - - &.shortcuts .shortcut-mappings { - display: inline-block; - margin-right: 5px; - } - ul { margin: 0; padding: 0; @@ -848,12 +839,56 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { .frequent-items-dropdown-container { display: flex; flex-direction: row; - width: 500px; - height: 354px; + height: $grid-size * 40; + + &.with-deprecated-styles { + width: 500px; + height: 354px; + + .section-header, + .frequent-items-list-container li.section-empty { + padding: 0 $gl-padding; + } + + .search-input-container { + position: relative; + padding: 4px $gl-padding; + + .search-icon { + position: absolute; + top: 13px; + right: 25px; + color: $gray-300; + } + } + + @include media-breakpoint-down(xs) { + flex-direction: column; + width: 100%; + height: auto; + flex: 1; + + .frequent-items-dropdown-sidebar, + .frequent-items-dropdown-content { + width: 100%; + } + + .frequent-items-dropdown-sidebar { + border-bottom: 1px solid $border-color; + border-right: 0; + } + } + + .frequent-items-list-container { + width: auto; + height: auto; + padding-bottom: 0; + } + } .frequent-items-dropdown-sidebar, .frequent-items-dropdown-content { - padding: 8px 0; + @include gl-pt-3; } .loading-animation { @@ -870,32 +905,13 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { width: 70%; } - @include media-breakpoint-down(xs) { - flex-direction: column; - width: 100%; - height: auto; - flex: 1; - - .frequent-items-dropdown-sidebar, - .frequent-items-dropdown-content { - width: 100%; - } - - .frequent-items-dropdown-sidebar { - border-bottom: 1px solid $border-color; - border-right: 0; - } - } - .section-header, .frequent-items-list-container li.section-empty { - padding: 0 $gl-padding; color: $gl-text-color-secondary; font-size: $gl-font-size; } .frequent-items-list-container { - height: 304px; padding: 8px 0; overflow-y: auto; @@ -908,36 +924,16 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { } } - .search-input-container { - position: relative; - padding: 4px $gl-padding; - - .search-icon { - position: absolute; - top: 13px; - right: 25px; - color: $gray-300; - } - } - .section-header { font-weight: 700; margin-top: 8px; } - - @include media-breakpoint-down(xs) { - .frequent-items-list-container { - width: auto; - height: auto; - padding-bottom: 0; - } - } } .frequent-items-list-item-container { .frequent-items-item-avatar-container, .frequent-items-item-metadata-container { - float: left; + flex-shrink: 0; } .frequent-items-item-metadata-container { diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/editor-lite.scss index 78995c6e4f5..05b53e0c3d8 100644 --- a/app/assets/stylesheets/framework/editor-lite.scss +++ b/app/assets/stylesheets/framework/editor-lite.scss @@ -11,7 +11,7 @@ &::before { content: ''; - @include spinner(32px, 3px); + @include spinner-deprecated(32px, 3px); @include gl-absolute; @include gl-z-index-1; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 432be7d0b3f..7566a533911 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -1,3 +1,5 @@ +$top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important; + .navbar-gitlab { padding: 0 16px; z-index: $header-zindex; @@ -254,6 +256,7 @@ } } + .top-nav-toggle, > button { background: transparent; border: 0; @@ -605,3 +608,60 @@ @include media-breakpoint-down(xs) { margin-right: 3px; } } + +.toggle-mobile-nav { + display: none; + background-color: transparent; + border: 0; + padding: 6px 16px; + margin: 0 0 0 -15px; + height: 46px; + color: $gl-text-color; + + @include media-breakpoint-down(sm) { + display: flex; + align-items: center; + + i { + font-size: 18px; + } + + + .breadcrumbs-links { + padding-left: $gl-padding; + border-left: 1px solid $gl-text-color-quaternary; + } + } +} + +.top-nav-container-view { + .gl-new-dropdown & .gl-search-box-by-type { + @include gl-m-0; + } + + .frequent-items-list-item-container > a:hover { + background-color: $top-nav-hover-bg; + } +} + +.top-nav-toggle { + .dropdown-icon { + @include gl-mr-3; + } + + .dropdown-chevron { + top: 0; + } +} + +.top-nav-menu-item { + color: var(--indigo-900, $theme-indigo-900) !important; + + &.active, + &:hover { + background-color: $top-nav-hover-bg; + } + + .gl-icon { + color: inherit !important; + } +} diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss index 4d5032ac674..8baf70da0c6 100644 --- a/app/assets/stylesheets/framework/issue_box.scss +++ b/app/assets/stylesheets/framework/issue_box.scss @@ -5,16 +5,7 @@ */ .status-box { - - /* Extra small devices (phones, less than 768px) */ - /* No media query since this is the default in Bootstrap */ - padding: 5px 11px; - margin-top: 4px; - /* Small devices (tablets, 768px and up) */ - @include media-breakpoint-up(sm) { - padding: 0 $gl-btn-padding; - margin-top: 5px; - } + padding: 0 $gl-btn-padding; border-radius: $border-radius-default; display: block; diff --git a/app/assets/stylesheets/framework/kbd.scss b/app/assets/stylesheets/framework/kbd.scss new file mode 100644 index 00000000000..05991bc16fd --- /dev/null +++ b/app/assets/stylesheets/framework/kbd.scss @@ -0,0 +1,15 @@ +kbd { + display: inline-block; + padding: 3px 5px; + font-size: $gl-font-size-monospace-sm; + line-height: 10px; + color: var(--gray-700, $gray-700); + vertical-align: middle; + background-color: var(--gray-10, $gray-10); + border-width: 1px; + border-style: solid; + border-color: var(--gray-100, $gray-100) var(--gray-100, $gray-100) var(--gray-200, $gray-200); + border-image: none; + border-radius: 3px; + box-shadow: 0 -1px 0 var(--gray-200, $gray-200) inset; +} diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index df2ba718c72..a3e8b2c245c 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -146,7 +146,7 @@ ul.content-list { > .btn, > .btn-group, > .dropdown.inline { - margin-right: $gl-padding-top; + margin-right: $grid-size; display: inline-block; margin-top: 3px; margin-bottom: 4px; diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 27b7cac2df5..f904ef11f5b 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -182,6 +182,11 @@ width: 100%; } + /* This resets the width of the control so that the search button doesn't wrap */ + .gl-search-box-by-click .form-control { + width: 1%; + } + .dropdown-menu-toggle { margin-bottom: 0; } diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss index c8eadce5c51..afd2e7ff757 100644 --- a/app/assets/stylesheets/framework/spinner.scss +++ b/app/assets/stylesheets/framework/spinner.scss @@ -1,16 +1,20 @@ -@mixin spinner-color($color) { +/** +* Do not use these spinner mixins. Rely on GitLab UI +* GlLoadingIcon component instead. +*/ +@mixin spinner-color-deprecated($color) { border-color: rgba($color, 0.25); border-top-color: $color; } -@mixin spinner-size($size, $border-width) { +@mixin spinner-size-deprecated($size, $border-width) { width: $size; height: $size; border-width: $border-width; @include webkit-prefix(transform-origin, 50% 50% calc((#{$size} / 2) + #{$border-width})); } -@keyframes spinner-rotate { +@keyframes spinner-rotate-deprecated { 0% { transform: rotate(0); } @@ -20,47 +24,16 @@ } } -@mixin spinner($size: 16px, $border-width: 2px, $color: $gray-700) { +@mixin spinner-deprecated($size: 16px, $border-width: 2px, $color: $gray-700) { border-radius: 50%; position: relative; margin: 0 auto; - animation-name: spinner-rotate; + animation-name: spinner-rotate-deprecated; animation-duration: 0.6s; animation-timing-function: linear; animation-iteration-count: infinite; border-style: solid; display: inline-flex; - @include spinner-size($size, $border-width); - @include spinner-color($color); -} - -.spinner { - @include spinner; - - &.spinner-md { - @include spinner-size(32px, 3px); - } - - &.spinner-lg { - @include spinner-size(64px, 4px); - } - - &.spinner-dark { - @include spinner-color($gray-700); - } - - &.spinner-light { - @include spinner-color($white); - } -} - -.btn { - .spinner, - .gl-spinner { - vertical-align: text-bottom; - } -} - -.spin { - animation: spinner-rotate 2s infinite linear; + @include spinner-size-deprecated($size, $border-width); + @include spinner-color-deprecated($color); } diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index 1504f3ee50f..9b38e842635 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -50,6 +50,12 @@ img.avatar { margin-right: $gl-padding; + + @include media-breakpoint-down(sm) { + width: $gl-spacing-scale-6; + height: $gl-spacing-scale-6; + margin-right: $gl-padding-8; + } } .controls { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 648ae29e212..603b05efe10 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -81,22 +81,6 @@ word-break: keep-all; } - kbd { - display: inline-block; - padding: 3px 5px; - font-size: 11px; - line-height: 10px; - color: $gray-700; - vertical-align: middle; - background-color: $gray-10; - border-width: 1px; - border-style: solid; - border-color: $gray-100 $gray-100 $gray-200; - border-image: none; - border-radius: 3px; - box-shadow: 0 -1px 0 $gray-200 inset; - } - h1 { font-size: 1.75em; font-weight: $gl-font-weight-bold; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 18aa0d3013d..bfb21d7112b 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -283,6 +283,8 @@ $indigo-700: #4b4ba3; $indigo-800: #393982; $indigo-900: #292961; $indigo-950: #1a1a40; +// To do this variant right for darkmode, we need to create a variable for it. +$indigo-900-alpha-008: rgba($indigo-900, 0.08); $theme-blue-50: #f4f8fc; $theme-blue-100: #e6edf5; diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss index 0d6f360112b..2f8602a212d 100644 --- a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss +++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss @@ -147,11 +147,11 @@ display: block; &:hover { - box-shadow: inset 0 0 0 0.0625rem $dropdown-toggle-active-border-color; + box-shadow: inset 0 0 0 2px var(--gray-400, $gray-400); background-color: var(--gray-50, $gray-50); } - .spinner, + .gl-spinner, svg { width: $ci-action-dropdown-svg-size; height: $ci-action-dropdown-svg-size; @@ -176,12 +176,6 @@ li { position: relative; - // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered - &:hover > .mini-pipeline-graph-dropdown-item, - &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item { - @extend .mini-pipeline-graph-dropdown-item:hover; - } - // link to the build .mini-pipeline-graph-dropdown-item { align-items: center; @@ -216,13 +210,16 @@ display: block; } } + } - &:hover, - &:focus { - outline: none; - text-decoration: none; - background-color: var(--gray-100, $gray-50); - } + // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered + &:hover > .mini-pipeline-graph-dropdown-item, + &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item, + .mini-pipeline-graph-dropdown-item:hover, + .mini-pipeline-graph-dropdown-item:focus { + outline: none; + text-decoration: none; + background-color: var(--gray-100, $gray-50); } } } diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss index b91850f1775..ec41909beec 100644 --- a/app/assets/stylesheets/page_bundles/build.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -11,10 +11,6 @@ color: var(--orange-600, $orange-600); background-color: var(--orange-50, $orange-50); border: 1px solid var(--border-color, $border-color); - padding: 3px 12px; - margin: auto; - align-items: center; - z-index: 1; .with-performance-bar & { top: $header-height + $performance-bar-height; @@ -202,10 +198,6 @@ } .build-job { - &.active { - font-weight: $gl-font-weight-bold; - } - &.retried { background-color: var(--gray-10, $gray-10); } diff --git a/app/assets/stylesheets/page_bundles/dev_ops_report.scss b/app/assets/stylesheets/page_bundles/dev_ops_report.scss deleted file mode 100644 index 5c6019efce6..00000000000 --- a/app/assets/stylesheets/page_bundles/dev_ops_report.scss +++ /dev/null @@ -1,261 +0,0 @@ -@import 'mixins_and_variables_and_functions'; - -$space-between-cards: 8px; - -.devops-empty svg { - margin: 64px auto 32px; - max-width: 420px; -} - -.devops-header { - margin-top: $gl-padding; - margin-bottom: $gl-padding; - padding: 0 4px; - display: flex; - align-items: center; - - .devops-header-title { - font-size: 48px; - line-height: 1; - margin: 0; - } - - .devops-header-subtitle { - font-size: 22px; - line-height: 1; - color: var(--gl-text-color-secondary, $gl-text-color-secondary); - margin-left: 8px; - font-weight: $gl-font-weight-normal; - - .devops-header-icon { - vertical-align: px-to-rem(-$gl-spacing-scale-1); - } - - a { - font-size: 18px; - color: var(--gl-text-color-secondary, $gl-text-color-secondary); - - &:hover { - color: var(--blue-500, $blue-500); - } - } - } -} - -.devops-cards { - display: flex; - justify-content: center; - flex-wrap: wrap; -} - -.devops-card-wrapper { - display: flex; - flex-direction: column; - align-items: stretch; - text-align: center; - width: 50%; - border-color: var(--border-color, $border-color); - margin: 0 0 32px; - padding: $space-between-cards / 2; - position: relative; - - @include media-breakpoint-up(xs) { - width: percentage(1 / 4); - } - - @include media-breakpoint-up(sm) { - width: percentage(1 / 5); - } - - @include media-breakpoint-up(md) { - width: percentage(1 / 6); - } - - @include media-breakpoint-up(lg) { - width: percentage(1 / 10); - } -} - -.devops-card { - border: solid 1px var(--border-color, $border-color); - border-radius: 3px; - border-top-width: 3px; - display: flex; - flex-direction: column; - flex-grow: 1; -} - -.devops-card-low { - border-top-color: var(--red-400, $red-400); - - .board-card-score-big { - background-color: var(--red-50, $red-50); - } -} - -.devops-card-average { - border-top-color: var(--orange-200, $orange-200); - - .board-card-score-big { - background-color: var(--orange-50, $orange-50); - } -} - -.devops-card-high { - border-top-color: var(--green-400, $green-400); - - .board-card-score-big { - background-color: var(--green-50, $green-50); - } -} - -.devops-card-title { - margin: $gl-padding auto auto; - max-width: 100px; - - h3 { - font-size: 14px; - margin: 0 0 2px; - } - - .light-text { - font-size: 13px; - line-height: 1.25; - color: var(--gl-text-color-secondary, $gl-text-color-secondary); - } -} - -.board-card-scores { - display: flex; - justify-content: space-around; - align-items: center; - margin: $gl-padding $gl-btn-padding; - line-height: 1; -} - -.board-card-score { - color: var(--gl-text-color-secondary, $gl-text-color-secondary); - - .board-card-score-name { - font-size: 13px; - margin-top: 4px; - } -} - -.board-card-score-value { - font-size: 16px; - color: var(--gl-text-color, $gl-text-color); - font-weight: $gl-font-weight-normal; -} - -.board-card-score-big { - border-top: 2px solid var(--border-color, $border-color); - border-bottom: 1px solid var(--border-color, $border-color); - font-size: 22px; - padding: 10px 0; - font-weight: $gl-font-weight-normal; -} - -.board-card-buttons { - display: flex; - - > * { - font-size: 16px; - color: var(--gl-text-color-secondary, $gl-text-color-secondary); - padding: 10px; - flex-grow: 1; - - &:hover { - background-color: var(--border-color, $border-color); - color: var(--border-color, $border-color); - } - - + * { - border-left: solid 1px var(--border-color, $border-color); - } - } -} - -.devops-steps { - margin-top: $gl-padding; - height: 1px; - min-width: 100%; - justify-content: space-around; - position: relative; - background: var(--border-color, $border-color); -} - -.devops-step { - $step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%; - @each $pos in $step-positions { - $i: index($step-positions, $pos); - - &:nth-child(#{$i}) { - left: $pos; - } - } - - position: absolute; - transform-origin: 75% 50%; - padding: 8px; - height: 50px; - width: 50px; - border-radius: 3px; - display: flex; - flex-direction: column; - align-items: center; - border: solid 1px var(--border-color, $border-color); - background: var(--white, $white); - transform: translate(-50%, -50%); - color: var(--gl-text-color-secondary, $gl-text-color-secondary); - fill: var(--gl-text-color-secondary, $gl-text-color-secondary); - box-shadow: 0 2px 4px var(--dropdown-shadow-color, $dropdown-shadow-color); - - &:hover { - padding: 8px 10px; - fill: currentColor; - z-index: 100; - height: auto; - width: auto; - - .devops-step-title { - max-height: 2em; - opacity: 1; - transition: opacity 0.2s; - } - - svg { - transform: scale(1.5); - margin: $gl-btn-padding; - } - } - - svg { - transition: transform 0.1s; - width: 30px; - height: 30px; - min-height: 30px; - min-width: 30px; - } -} - -.devops-step-title { - max-height: 0; - opacity: 0; - text-transform: uppercase; - margin: $gl-vert-padding 0 0; - text-align: center; - font-size: 12px; -} - -.devops-high-score { - color: var(--green-400, $green-400); -} - -.devops-average-score { - color: var(--orange-500, $orange-500); -} - -.devops-low-score { - color: var(--red-400, $red-400); -} diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss index db4be3f18e8..4beb5edbe7b 100644 --- a/app/assets/stylesheets/page_bundles/jira_connect.scss +++ b/app/assets/stylesheets/page_bundles/jira_connect.scss @@ -45,3 +45,9 @@ $header-height: 40px; margin-left: auto; margin-right: auto; } + +// needed for external_link +svg.s16 { + width: 16px; + height: 16px; +} diff --git a/app/assets/stylesheets/page_bundles/merge_conflicts.scss b/app/assets/stylesheets/page_bundles/merge_conflicts.scss index d7473d2c942..9fe56fd337f 100644 --- a/app/assets/stylesheets/page_bundles/merge_conflicts.scss +++ b/app/assets/stylesheets/page_bundles/merge_conflicts.scss @@ -173,22 +173,5 @@ text-align: right; padding: $gl-padding-top $gl-padding; color: var(--gl-text-color, $gl-text-color); - - .discard-actions { - display: inline-block; - margin-left: 10px; - } - } - - .resolve-conflicts-form { - h4 { - margin-top: 0; - } - - .resolve-info { - @media(max-width: map-get($grid-breakpoints, lg)-1) { - margin-bottom: $gl-padding; - } - } } } diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 9fdc30359f8..5e9dd883635 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -7,6 +7,10 @@ .diff-files-holder { flex: 1; min-width: 0; + + .vue-recycle-scroller__item-wrapper { + overflow: visible; + } } .with-system-header { diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss index 858e13fc558..03dd12ec230 100644 --- a/app/assets/stylesheets/page_bundles/milestone.scss +++ b/app/assets/stylesheets/page_bundles/milestone.scss @@ -38,18 +38,6 @@ $status-box-line-height: 26px; color: var(--blue-600, $blue-600); } } - - .status-box { - font-size: $tooltip-font-size; - margin-top: 0; - margin-right: $gl-padding-4; - line-height: $status-box-line-height; - - @include media-breakpoint-down(xs) { - line-height: unset; - padding: $gl-padding-4 $gl-input-padding; - } - } } } @@ -199,11 +187,6 @@ $status-box-line-height: 26px; align-items: center; flex-wrap: wrap; - .status-box { - margin-top: 0; - order: 1; - } - .milestone-buttons { margin-left: auto; order: 2; diff --git a/app/assets/stylesheets/page_bundles/new_namespace.scss b/app/assets/stylesheets/page_bundles/new_namespace.scss new file mode 100644 index 00000000000..60aa3c8f29f --- /dev/null +++ b/app/assets/stylesheets/page_bundles/new_namespace.scss @@ -0,0 +1,28 @@ +@import 'mixins_and_variables_and_functions'; + +$new-namespace-panel-illustration-width: 215px; +$new-namespace-panel-height: 240px; + +.new-namespace-panel-illustration { + width: $new-namespace-panel-illustration-width; +} + +.new-namespace-panel-wrapper { + @include media-breakpoint-down(md) { + width: 100%; + } + width: 50%; +} + +.new-namespace-panel { + &:hover { + background-color: $gray-10; + } + + color: $purple-700; + min-height: $new-namespace-panel-height; + text-align: center; + @include media-breakpoint-up(lg) { + text-align: left; + } +} diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss index 5eaf91c3017..ddc638197ca 100644 --- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss +++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss @@ -95,7 +95,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi @include gl-font-weight-normal; &.label-dark { - @include gl-text-gray-900; + color: var(--gray-900, $gray-900); } &.label-bold { diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss index 2f3cf889549..c9171eb4fc7 100644 --- a/app/assets/stylesheets/page_bundles/pipeline.scss +++ b/app/assets/stylesheets/page_bundles/pipeline.scss @@ -306,11 +306,6 @@ } } - // ensure .build-content has hover style when action-icon is hovered - .ci-job-dropdown-container:hover .build-content { - @extend .build-content:hover; - } - .ci-status-icon svg { height: 24px; width: 24px; @@ -330,6 +325,7 @@ @include build-content(); } + .ci-job-dropdown-container:hover .build-content, a.build-content:hover, button.build-content:hover { background-color: var(--gray-100, $gray-100); @@ -409,7 +405,7 @@ fill: var(--gray-500, $gray-500); } - .spinner { + .gl-spinner { top: 2px; } diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss index 6ef7f912ea9..ace91d197b6 100644 --- a/app/assets/stylesheets/page_bundles/pipelines.scss +++ b/app/assets/stylesheets/page_bundles/pipelines.scss @@ -182,11 +182,6 @@ button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle { border-bottom-color: $border-color; } - &::after { - margin-top: 1px; - border-bottom-color: $white; - } - /** * Center dropdown menu in mini graph */ diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss index 3e20ca9c62f..e7813e3b56e 100644 --- a/app/assets/stylesheets/page_bundles/todos.scss +++ b/app/assets/stylesheets/page_bundles/todos.scss @@ -8,8 +8,6 @@ .todos-list > .todo { // workaround because we cannot use border-collapse border-top: 1px solid transparent; - display: flex; - flex-direction: row; &:hover { background-color: var(--blue-50, $blue-50); @@ -26,25 +24,6 @@ } } - .todo-avatar, - .todo-actions { - @include transition(opacity); - flex: 0 0 auto; - } - - .todo-actions { - display: flex; - justify-content: center; - flex-direction: column; - margin-left: 10px; - min-width: 55px; - } - - .todo-item { - flex: 0 1 100%; - min-width: 0; - } - &.todo-pending.done-reversible { &:hover { border-color: var(--border-color, $border-color); @@ -71,58 +50,22 @@ .todo-item { @include transition(opacity); - .todo-title { - > .title-item { - &:first-child { - margin-left: 0; - } - - &:last-child { - margin-right: 0; - } - } - - .todo-label { - flex: 0 1 auto; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - .status-box { - margin: 0; - float: none; - display: inline-block; - font-weight: $gl-font-weight-normal; - padding: 0 5px; line-height: inherit; - font-size: 14px; } .todo-label, .todo-project { a { - font-weight: $gl-font-weight-normal; color: var(--blue-600, $blue-600); } } .todo-body { - .badge.badge-pill, p { color: var(--gl-text-color, $gl-text-color); } - .md { - color: $gl-grayish-blue; - font-size: $gl-font-size; - } - - code { - white-space: pre-wrap; - } - pre { border: 0; background: var(--gray-50, $gray-50); @@ -139,120 +82,13 @@ float: none; } - p:last-child { - margin-bottom: 0; - } - } - - .gl-label-scoped { - --label-inset-border: inset 0 0 0 1px currentColor; - } -} - -@include media-breakpoint-down(lg) { - .todos-filters { - .filter-categories { - width: 75%; - - .filter-item { - margin-bottom: 10px; - } + .gl-label-scoped { + --label-inset-border: inset 0 0 0 1px currentColor; } - } -} -@include media-breakpoint-down(sm) { - .container-fluid .todos-list-container { - margin: 0 (-$gl-padding); - } - - .todo { - .avatar { - display: none; - } - } - - .todo-item { - .todo-title { - margin-bottom: 10px; - - .todo-label { - white-space: normal; - } - } - - .todo-body { - margin: 0; + @include media-breakpoint-down(sm) { border-left: 2px solid var(--border-color, $border-color); padding-left: 10px; } } - - .todos-filters { - .filter-categories { - width: auto; - } - - .dropdown-menu-toggle { - width: 100%; - } - - .dropdown-menu-toggle-sort { - width: auto; - } - } -} - -.todos-empty { - display: flex; - flex-direction: column; - max-width: 900px; - margin-left: auto; - margin-right: auto; - - @include media-breakpoint-up(sm) { - flex-direction: row; - padding-top: 80px; - } -} - -.todos-empty-content { - align-self: center; - max-width: 480px; -} - -.todos-empty-hero { - width: 200px; - margin-left: auto; - margin-right: auto; - - @include media-breakpoint-up(sm) { - width: 300px; - margin-right: 0; - order: 2; - } -} - -.todos-all-done { - padding-top: 20px; - - @include media-breakpoint-up(sm) { - padding-top: 50px; - } - - > svg { - display: block; - max-width: 300px; - margin: 0 auto 20px; - } - - p { - max-width: 470px; - margin-left: auto; - margin-right: auto; - } - - a { - font-weight: $gl-font-weight-bold; - } } diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 14cff5b038a..c177d0b74a2 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -37,10 +37,6 @@ .file-title { @include gl-font-monospace; - line-height: 35px; - padding-top: 7px; - padding-bottom: 7px; - display: flex; } .editor-ref { @@ -69,19 +65,15 @@ } .file-buttons { - display: flex; flex: 1; - justify-content: flex-end; } .soft-wrap-toggle { - display: inline-block; - vertical-align: top; font-family: $regular-font; - margin: 0 $btn-side-margin; + margin-left: $gl-padding-8; .soft-wrap { - display: block; + display: inline-flex; } .no-wrap { @@ -94,7 +86,7 @@ } .no-wrap { - display: block; + display: inline-flex; } } } @@ -111,17 +103,21 @@ .new-file-path { max-width: none; width: 100%; - margin-bottom: 3px; + margin-top: $gl-padding-8; } .file-buttons { - display: block; + display: flex; + flex-direction: column; width: 100%; - margin-bottom: 10px; + + .md-header-toolbar { + margin: $gl-padding 0; + } .soft-wrap-toggle { width: 100%; - margin: 3px 0; + margin-left: 0; } @media(max-width: map-get($grid-breakpoints, md)-1) { @@ -168,7 +164,6 @@ .license-selector, .gitignore-selector, .gitlab-ci-yml-selector, - .gitlab-ci-syntax-yml-selector, .dockerfile-selector, .template-type-selector, .metrics-dashboard-selector { diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss index c05216ac6e6..9182292ffd3 100644 --- a/app/assets/stylesheets/pages/help.scss +++ b/app/assets/stylesheets/pages/help.scss @@ -1,30 +1,30 @@ -.shortcut-mappings { - font-size: 12px; - color: $gray-700; - - tbody:first-child tr:first-child { - padding-top: 0; +.shortcut-help { + &-body { + height: 80vh; + overflow-y: scroll; } - th { - padding-top: 15px; - line-height: 1.5; - color: $help-shortcut-header-color; - text-align: left; + &-container { + column-count: 1; + @include media-breakpoint-up(md) { + column-count: 2; + } + column-gap: 1rem; } - td { - padding-top: 3px; - padding-bottom: 3px; - vertical-align: top; - line-height: 20px; - } + &-mapping { + overflow: hidden; + break-inside: avoid; + + &-title { + margin-left: 40%; + } - .shortcut { - padding-right: 10px; - color: $gray-300; - text-align: right; - white-space: nowrap; + kbd { + margin: 0.1rem 0; + line-height: unset; + font-size: unset; + } } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index b9f5a427a24..0437fa19752 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -217,7 +217,6 @@ .title { color: $gl-text-color; - margin-bottom: $gl-padding-4; line-height: $gl-line-height-20; .avatar { diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 59768f4cda8..c025d8569a7 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -369,10 +369,6 @@ table { .btn { float: none; width: 100%; - - &:not(:last-child) { - margin-bottom: 10px; - } } } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 801dd44be8e..01739c7eb3e 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -372,7 +372,7 @@ $system-note-svg-size: 16px; top: $mr-tabs-height + $header-height; .with-performance-bar & { - top: 126px; + top: 123px; } } @@ -672,6 +672,7 @@ $system-note-svg-size: 16px; align-items: center; margin-left: 10px; color: $gray-400; + margin-top: -4px; @include notes-media('max', map-get($grid-breakpoints, sm) - 1) { float: none; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 16f96ebadc9..dfd64d0773c 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -237,11 +237,6 @@ line-height: 34px; margin: 0; - > li + li::before { - padding: 0 3px; - color: $gray-300; - } - a { color: $gl-text-color; } @@ -1032,11 +1027,6 @@ pre.light-well { } } -.issuable-footer { - padding-top: $gl-padding; - padding-bottom: 37px; -} - .project-ci-linter { .ci-editor { height: 400px; diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss index 346b3f61caa..7d74070b4f2 100644 --- a/app/assets/stylesheets/pages/settings_ci_cd.scss +++ b/app/assets/stylesheets/pages/settings_ci_cd.scss @@ -11,10 +11,6 @@ .trigger-actions { white-space: nowrap; - - .btn { - margin-left: 10px; - } } .auto-devops-card { diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index 11b4bde74a6..9d98fe5c739 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -70,6 +70,7 @@ $indigo-700: #a6a6de; $indigo-800: #d1d1f0; $indigo-900: #ebebfa; $indigo-950: #f7f7ff; +$indigo-900-alpha-008: rgba($indigo-900, 0.08); $gray-lightest: #222; $gray-light: $gray-50; @@ -160,6 +161,7 @@ body.gl-dark { --indigo-800: #{$indigo-800}; --indigo-900: #{$indigo-900}; --indigo-950: #{$indigo-950}; + --indigo-900-alpha-008: #{$indigo-900-alpha-008}; --gl-text-color: #{$gray-900}; --border-color: #{$border-color}; @@ -232,9 +234,7 @@ $well-inner-border: $gray-200; } // white-ish text for light labels -// and for scoped label value (the right section) -.gl-label-text-light.gl-label-text-light, -.gl-label-text-dark + .gl-label-text-dark { +.gl-label-text-light.gl-label-text-light { color: $gray-900; } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 024162eba3e..c22a1ae1187 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -172,3 +172,38 @@ width: 50%; } } + +.gl-sm-mr-3 { + @include media-breakpoint-up(sm) { + margin-right: $gl-spacing-scale-3; + } +} + +.gl-mb-n3 { + margin-bottom: -$gl-spacing-scale-3; +} + +// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1408 +$gl-line-height-42: px-to-rem(42px); + +.gl-line-height-42 { + line-height: $gl-line-height-42; +} + +.gl-w-grid-size-30 { + width: $grid-size * 30; +} + +.gl-w-grid-size-40 { + width: $grid-size * 40; +} + +// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209 +.gl-max-w-none\! { + max-width: none !important; +} + +// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209 +.gl-max-h-none\! { + max-height: none !important; +} diff --git a/app/channels/graphql_channel.rb b/app/channels/graphql_channel.rb new file mode 100644 index 00000000000..d364cc2b64b --- /dev/null +++ b/app/channels/graphql_channel.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# This is based on https://github.com/rmosolgo/graphql-ruby/blob/v1.11.8/lib/graphql/subscriptions/action_cable_subscriptions.rb#L19-L82 +# modified to work with our own ActionCableLink client + +class GraphqlChannel < ApplicationCable::Channel # rubocop:disable Gitlab/NamespacedClass + def subscribed + @subscription_ids = [] + + query = params['query'] + variables = Gitlab::Graphql::Variables.new(params['variables']).to_h + operation_name = params['operationName'] + + result = GitlabSchema.execute( + query, + context: context, + variables: variables, + operation_name: operation_name + ) + + payload = { + result: result.to_h, + more: result.subscription? + } + + # Track the subscription here so we can remove it + # on unsubscribe. + if result.context[:subscription_id] + @subscription_ids << result.context[:subscription_id] + end + + transmit(payload) + end + + def unsubscribed + @subscription_ids.each do |sid| + GitlabSchema.subscriptions.delete_subscription(sid) + end + end + + rescue_from Gitlab::Graphql::Variables::Invalid do |exception| + transmit({ errors: [{ message: exception.message }] }) + end + + private + + # When modifying the context, also update GraphqlController#context if needed + # so that we have similar context when executing queries, mutations, and subscriptions + # + # Objects added to the context may also need to be reloaded in + # `Subscriptions::BaseSubscription` so that they are not stale + def context + # is_sessionless_user is always false because we only support cookie auth in ActionCable + { channel: self, current_user: current_user, is_sessionless_user: false } + end +end diff --git a/app/channels/issues_channel.rb b/app/channels/issues_channel.rb deleted file mode 100644 index 5f3909b7716..00000000000 --- a/app/channels/issues_channel.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class IssuesChannel < ApplicationCable::Channel - def subscribed - project = Project.find_by_full_path(params[:project_path]) - return reject unless project - - issue = project.issues.find_by_iid(params[:iid]) - return reject unless issue && Ability.allowed?(current_user, :read_issue, issue) - - stream_for issue - end -end diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb index 956e03cef07..bab2d5639a7 100644 --- a/app/controllers/admin/application_controller.rb +++ b/app/controllers/admin/application_controller.rb @@ -9,4 +9,4 @@ class Admin::ApplicationController < ApplicationController layout 'admin' end -Admin::ApplicationController.prepend_if_ee('EE::Admin::ApplicationController') +Admin::ApplicationController.prepend_mod_with('Admin::ApplicationController') diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 646a6dffd10..80cb04ac496 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -49,7 +49,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController def integrations return not_found unless instance_level_integrations? - @integrations = Service.find_or_initialize_all_non_project_specific(Service.for_instance).sort_by(&:title) + @integrations = Integration.find_or_initialize_all_non_project_specific(Integration.for_instance).sort_by(&:title) end def update @@ -292,4 +292,4 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end end -Admin::ApplicationSettingsController.prepend_if_ee('EE::Admin::ApplicationSettingsController') +Admin::ApplicationSettingsController.prepend_mod_with('Admin::ApplicationSettingsController') diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb index 9bb73c822b0..c29b5224b09 100644 --- a/app/controllers/admin/cohorts_controller.rb +++ b/app/controllers/admin/cohorts_controller.rb @@ -6,6 +6,6 @@ class Admin::CohortsController < Admin::ApplicationController # Backwards compatibility. Remove it and routing in 14.0 # @see https://gitlab.com/gitlab-org/gitlab/-/issues/299303 def index - redirect_to admin_users_path(tab: 'cohorts') + redirect_to cohorts_admin_users_path end end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index da89276f5eb..46e5a508a1b 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -24,4 +24,4 @@ class Admin::DashboardController < Admin::ApplicationController end end -Admin::DashboardController.prepend_if_ee('EE::Admin::DashboardController') +Admin::DashboardController.prepend_mod_with('Admin::DashboardController') diff --git a/app/controllers/admin/dev_ops_report_controller.rb b/app/controllers/admin/dev_ops_report_controller.rb index 4178e51fb13..a235af7c538 100644 --- a/app/controllers/admin/dev_ops_report_controller.rb +++ b/app/controllers/admin/dev_ops_report_controller.rb @@ -24,4 +24,4 @@ class Admin::DevOpsReportController < Admin::ApplicationController end end -Admin::DevOpsReportController.prepend_if_ee('EE::Admin::DevOpsReportController') +Admin::DevOpsReportController.prepend_mod_with('Admin::DevOpsReportController') diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index e14cfc236cf..5b33ee78e8c 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -117,4 +117,4 @@ class Admin::GroupsController < Admin::ApplicationController end end -Admin::GroupsController.prepend_if_ee('EE::Admin::GroupsController') +Admin::GroupsController.prepend_mod_with('Admin::GroupsController') diff --git a/app/controllers/admin/health_check_controller.rb b/app/controllers/admin/health_check_controller.rb index e013b5fbd72..5733929c25e 100644 --- a/app/controllers/admin/health_check_controller.rb +++ b/app/controllers/admin/health_check_controller.rb @@ -14,4 +14,4 @@ class Admin::HealthCheckController < Admin::ApplicationController end end -Admin::HealthCheckController.prepend_if_ee('EE::Admin::HealthCheckController') +Admin::HealthCheckController.prepend_mod_with('Admin::HealthCheckController') diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb index 4247446365c..316e6d9aa74 100644 --- a/app/controllers/admin/integrations_controller.rb +++ b/app/controllers/admin/integrations_controller.rb @@ -11,7 +11,7 @@ class Admin::IntegrationsController < Admin::ApplicationController private def find_or_initialize_non_project_specific_integration(name) - Service.find_or_initialize_non_project_specific_integration(name, instance: true) + Integration.find_or_initialize_non_project_specific_integration(name, instance: true) end def scoped_edit_integration_path(integration) diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb index be63bf4c7ce..6cc11b40de0 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -47,7 +47,7 @@ class Admin::LabelsController < Admin::ApplicationController format.html do redirect_to admin_labels_path, status: :found, notice: _('Label was removed') end - format.js + format.js { head :ok } end end diff --git a/app/controllers/admin/plan_limits_controller.rb b/app/controllers/admin/plan_limits_controller.rb index 0a5cdc06d61..88bc5ea0198 100644 --- a/app/controllers/admin/plan_limits_controller.rb +++ b/app/controllers/admin/plan_limits_controller.rb @@ -35,6 +35,7 @@ class Admin::PlanLimitsController < Admin::ApplicationController npm_max_file_size nuget_max_file_size pypi_max_file_size + terraform_module_max_file_size generic_packages_max_file_size ]) end diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index 39718793c1d..6fd1e9bb70e 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -89,4 +89,4 @@ class Admin::ProjectsController < Admin::ApplicationController end end -Admin::ProjectsController.prepend_if_ee('EE::Admin::ProjectsController') +Admin::ProjectsController.prepend_mod_with('Admin::ProjectsController') diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 576b148fbff..40ec68c1d46 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -7,9 +7,11 @@ class Admin::RunnersController < Admin::ApplicationController feature_category :continuous_integration + NUMBER_OF_RUNNERS_PER_PAGE = 30 + def index finder = Ci::RunnersFinder.new(current_user: current_user, params: params) - @runners = finder.execute + @runners = finder.execute.page(params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE) @active_runners_count = Ci::Runner.online.count @sort = finder.sort_key end diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index 9f951e838c8..d34773ee4dc 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -1,28 +1,28 @@ # frozen_string_literal: true class Admin::ServicesController < Admin::ApplicationController - include ServiceParams + include Integrations::Params - before_action :service, only: [:edit, :update] + before_action :integration, only: [:edit, :update] before_action :disable_query_limiting, only: [:index] feature_category :integrations def index - @activated_services = Service.for_template.active.sort_by(&:title) - @existing_instance_types = Service.for_instance.pluck(:type) # rubocop: disable CodeReuse/ActiveRecord + @activated_services = Integration.for_template.active.sort_by(&:title) + @existing_instance_types = Integration.for_instance.pluck(:type) # rubocop: disable CodeReuse/ActiveRecord end def edit - if service.nil? || Service.instance_exists_for?(service.type) + if integration.nil? || Integration.instance_exists_for?(integration.type) redirect_to admin_application_settings_services_path, alert: "Service is unknown or it doesn't exist" end end def update - if service.update(service_params[:service]) - PropagateServiceTemplateWorker.perform_async(service.id) if service.active? # rubocop:disable CodeReuse/Worker + if integration.update(integration_params[:integration]) + PropagateServiceTemplateWorker.perform_async(integration.id) if integration.active? # rubocop:disable CodeReuse/Worker redirect_to admin_application_settings_services_path, notice: 'Application settings saved successfully' @@ -34,9 +34,11 @@ class Admin::ServicesController < Admin::ApplicationController private # rubocop: disable CodeReuse/ActiveRecord - def service - @service ||= Service.find_by(id: params[:id], template: true) + def integration + @integration ||= Integration.find_by(id: params[:id], template: true) + @service ||= @integration # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/329759 end + alias_method :service, :integration # rubocop: enable CodeReuse/ActiveRecord def disable_query_limiting diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 8a090c8ef10..2e9229db56c 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -4,22 +4,29 @@ class Admin::UsersController < Admin::ApplicationController include RoutableActions include Analytics::UniqueVisitsHelper - before_action :user, except: [:index, :new, :create] + before_action :user, except: [:index, :cohorts, :new, :create] before_action :check_impersonation_availability, only: :impersonate before_action :ensure_destroy_prerequisites_met, only: [:destroy] + before_action :check_ban_user_feature_flag, only: [:ban] feature_category :users + PAGINATION_WITH_COUNT_LIMIT = 1000 + def index + return redirect_to cohorts_admin_users_path if params[:tab] == 'cohorts' + @users = User.filter_items(params[:filter]).order_name_asc @users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present? @users = users_with_included_associations(@users) @users = @users.sort_by_attribute(@sort = params[:sort]) @users = @users.page(params[:page]) + @users = @users.without_count if paginate_without_count? + end + def cohorts @cohorts = load_cohorts - - track_cohorts_visit if params[:tab] == 'cohorts' + track_cohorts_visit end def show @@ -124,6 +131,24 @@ class Admin::UsersController < Admin::ApplicationController end end + def ban + result = Users::BanService.new(current_user).execute(user) + + if result[:status] == :success + redirect_back_or_admin_user(notice: _("Successfully banned")) + else + redirect_back_or_admin_user(alert: _("Error occurred. User was not banned")) + end + end + + def unban + if update_user { |user| user.activate } + redirect_back_or_admin_user(notice: _("Successfully unbanned")) + else + redirect_back_or_admin_user(alert: _("Error occurred. User was not unbanned")) + end + end + def unlock if update_user { |user| user.unlock_access! } redirect_back_or_admin_user(alert: _("Successfully unlocked")) @@ -228,6 +253,12 @@ class Admin::UsersController < Admin::ApplicationController protected + def paginate_without_count? + counts = Gitlab::Database::Count.approximate_counts([User]) + + counts[User] > PAGINATION_WITH_COUNT_LIMIT + end + def users_with_included_associations(users) users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord end @@ -313,18 +344,20 @@ class Admin::UsersController < Admin::ApplicationController access_denied! unless Gitlab.config.gitlab.impersonation_enabled end + def check_ban_user_feature_flag + access_denied! unless Feature.enabled?(:ban_user_feature_flag) + end + def log_impersonation_event Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username }) end def load_cohorts - if Gitlab::CurrentSettings.usage_ping_enabled - cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do - CohortsService.new.execute - end - - CohortsSerializer.new.represent(cohorts_results) + cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do + CohortsService.new.execute end + + CohortsSerializer.new.represent(cohorts_results) end def track_cohorts_visit @@ -334,4 +367,4 @@ class Admin::UsersController < Admin::ApplicationController end end -Admin::UsersController.prepend_if_ee('EE::Admin::UsersController') +Admin::UsersController.prepend_mod_with('Admin::UsersController') diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 607f3435394..00b9fb1060d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,6 +22,7 @@ class ApplicationController < ActionController::Base include Gitlab::Logging::CloudflareHelper include Gitlab::Utils::StrongMemoize include ::Gitlab::WithFeatureCategory + include FlocOptOut before_action :authenticate_user!, except: [:route_not_found] before_action :enforce_terms!, if: :should_enforce_terms? @@ -105,6 +106,10 @@ class ApplicationController < ActionController::Base redirect_back(fallback_location: default, **options) end + def check_if_gl_com_or_dev + render_404 unless ::Gitlab.dev_env_or_com? + end + def not_found render_404 end @@ -207,13 +212,13 @@ class ApplicationController < ActionController::Base end respond_to do |format| - format.any { head status } format.html do render template, layout: "errors", status: status, locals: { message: message } end + format.any { head status } end end @@ -223,8 +228,8 @@ class ApplicationController < ActionController::Base def render_403 respond_to do |format| - format.any { head :forbidden } format.html { render "errors/access_denied", layout: "errors", status: :forbidden } + format.any { head :forbidden } end end @@ -555,4 +560,4 @@ class ApplicationController < ActionController::Base end end -ApplicationController.prepend_ee_mod +ApplicationController.prepend_mod diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 79e45bcf929..1c07245da08 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -71,4 +71,4 @@ class AutocompleteController < ApplicationController end end -AutocompleteController.prepend_if_ee('EE::AutocompleteController') +AutocompleteController.prepend_mod_with('AutocompleteController') diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 347bf1f4fa8..003ed45adb5 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -27,7 +27,9 @@ module Boards list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params) issues = issues_from(list_service) - Issue.move_nulls_to_end(issues) if Gitlab::Database.read_write? + if Gitlab::Database.read_write? && !board.disabled_for?(current_user) + Issue.move_nulls_to_end(issues) + end render_issues(issues, list_service.metadata) end @@ -158,4 +160,4 @@ module Boards end end -Boards::IssuesController.prepend_if_ee('EE::Boards::IssuesController') +Boards::IssuesController.prepend_mod_with('Boards::IssuesController') diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb index 19a4508c061..8ab8337a3ad 100644 --- a/app/controllers/boards/lists_controller.rb +++ b/app/controllers/boards/lists_controller.rb @@ -33,10 +33,10 @@ module Boards service = Boards::Lists::UpdateService.new(board_parent, current_user, update_list_params) result = service.execute(list) - if result[:status] == :success + if result.success? head :ok else - head result[:http_status] + head result.http_status end end @@ -99,4 +99,4 @@ module Boards end end -Boards::ListsController.prepend_if_ee('EE::Boards::ListsController') +Boards::ListsController.prepend_mod_with('Boards::ListsController') diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index c64301f72ba..32de9e69c85 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -62,6 +62,7 @@ class Clusters::ClustersController < Clusters::BaseController def show if params[:tab] == 'integrations' @prometheus_integration = Clusters::IntegrationPresenter.new(@cluster.find_or_build_integration_prometheus) + @elastic_stack_integration = Clusters::IntegrationPresenter.new(@cluster.find_or_build_integration_elastic_stack) end end @@ -362,4 +363,4 @@ class Clusters::ClustersController < Clusters::BaseController end end -Clusters::ClustersController.prepend_if_ee('EE::Clusters::ClustersController') +Clusters::ClustersController.prepend_mod_with('Clusters::ClustersController') diff --git a/app/controllers/clusters/integrations_controller.rb b/app/controllers/clusters/integrations_controller.rb index a8c7eb10136..17884a55242 100644 --- a/app/controllers/clusters/integrations_controller.rb +++ b/app/controllers/clusters/integrations_controller.rb @@ -24,7 +24,7 @@ module Clusters end def cluster_integration_params - params.require(:integration).permit(:application_type, :enabled) + params.permit(integration: [:enabled, :application_type]).require(:integration) end def cluster diff --git a/app/controllers/concerns/accepts_pending_invitations.rb b/app/controllers/concerns/accepts_pending_invitations.rb index cb66c1a055d..5601b7a7f79 100644 --- a/app/controllers/concerns/accepts_pending_invitations.rb +++ b/app/controllers/concerns/accepts_pending_invitations.rb @@ -6,7 +6,15 @@ module AcceptsPendingInvitations def accept_pending_invitations return unless resource.active_for_authentication? - clear_stored_location_for_resource if resource.accept_pending_invitations!.any? + if resource.pending_invitations.load.any? + resource.accept_pending_invitations! + clear_stored_location_for_resource + after_pending_invitations_hook + end + end + + def after_pending_invitations_hook + # no-op end def clear_stored_location_for_resource diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 87555a28eb8..4f4b204def8 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -177,4 +177,4 @@ module AuthenticatesWithTwoFactor end end -AuthenticatesWithTwoFactor.prepend_if_ee('EE::AuthenticatesWithTwoFactor') +AuthenticatesWithTwoFactor.prepend_mod_with('AuthenticatesWithTwoFactor') diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb index 79e6f027c2f..2f9edfad12d 100644 --- a/app/controllers/concerns/boards_actions.rb +++ b/app/controllers/concerns/boards_actions.rb @@ -7,12 +7,10 @@ module BoardsActions included do include BoardsResponses + before_action :authorize_read_board!, only: [:index, :show] before_action :boards, only: :index before_action :board, only: :show before_action :push_licensed_features, only: [:index, :show] - before_action do - push_frontend_feature_flag(:not_issuable_queries, parent, default_enabled: true) - end end def index @@ -21,7 +19,7 @@ module BoardsActions def show # Add / update the board in the recent visits table - Boards::Visits::CreateService.new(parent, current_user).execute(board) if request.format.html? + board_visit_service.new(parent, current_user).execute(board) if request.format.html? respond_with_board end @@ -54,6 +52,10 @@ module BoardsActions board_klass.to_type end + def board_visit_service + Boards::Visits::CreateService + end + def serializer BoardSerializer.new(current_user: current_user) end @@ -63,4 +65,4 @@ module BoardsActions end end -BoardsActions.prepend_if_ee('EE::BoardsActions') +BoardsActions.prepend_mod_with('BoardsActions') diff --git a/app/controllers/concerns/boards_responses.rb b/app/controllers/concerns/boards_responses.rb index 7307b7b4f8f..eb7392648a1 100644 --- a/app/controllers/concerns/boards_responses.rb +++ b/app/controllers/concerns/boards_responses.rb @@ -91,4 +91,4 @@ module BoardsResponses end end -BoardsResponses.prepend_if_ee('EE::BoardsResponses') +BoardsResponses.prepend_mod_with('BoardsResponses') diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb index 50e340dc9b1..b74e343f90b 100644 --- a/app/controllers/concerns/cycle_analytics_params.rb +++ b/app/controllers/concerns/cycle_analytics_params.rb @@ -43,4 +43,4 @@ module CycleAnalyticsParams end end -CycleAnalyticsParams.prepend_if_ee('EE::CycleAnalyticsParams') +CycleAnalyticsParams.prepend_mod_with('CycleAnalyticsParams') diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb index bf38e4ad117..c67e73d4e78 100644 --- a/app/controllers/concerns/enforces_two_factor_authentication.rb +++ b/app/controllers/concerns/enforces_two_factor_authentication.rb @@ -72,4 +72,4 @@ module EnforcesTwoFactorAuthentication end end -EnforcesTwoFactorAuthentication.prepend_if_ee('EE::EnforcesTwoFactorAuthentication') +EnforcesTwoFactorAuthentication.prepend_mod_with('EnforcesTwoFactorAuthentication') diff --git a/app/controllers/concerns/floc_opt_out.rb b/app/controllers/concerns/floc_opt_out.rb new file mode 100644 index 00000000000..3039af02bbb --- /dev/null +++ b/app/controllers/concerns/floc_opt_out.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module FlocOptOut + extend ActiveSupport::Concern + + included do + after_action :set_floc_opt_out_header, unless: :floc_enabled? + end + + def floc_enabled? + Gitlab::CurrentSettings.floc_enabled + end + + def set_floc_opt_out_header + response.headers['Permissions-Policy'] = 'interest-cohort=()' + end +end diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb new file mode 100644 index 00000000000..10122b4c77b --- /dev/null +++ b/app/controllers/concerns/integrations/params.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Integrations + module Params + extend ActiveSupport::Concern + + ALLOWED_PARAMS_CE = [ + :active, + :add_pusher, + :alert_events, + :api_key, + :api_url, + :bamboo_url, + :branches_to_be_notified, + :labels_to_be_notified, + :labels_to_be_notified_behavior, + :build_key, + :build_type, + :ca_pem, + :channel, + :channels, + :color, + :colorize_messages, + :comment_on_event_enabled, + :comment_detail, + :confidential_issues_events, + :confluence_url, + :datadog_site, + :datadog_env, + :datadog_service, + :default_irc_uri, + :device, + :disable_diffs, + :drone_url, + :enable_ssl_verification, + :external_wiki_url, + :google_iap_service_account_json, + :google_iap_audience_client_id, + :inherit_from_id, + # We're using `issues_events` and `merge_requests_events` + # in the view so we still need to explicitly state them + # here. `Service#event_names` would only give + # `issue_events` and `merge_request_events` (singular!) + # See app/helpers/services_helper.rb for how we + # make those event names plural as special case. + :issues_events, + :issues_url, + :jenkins_url, + :jira_issue_transition_automatic, + :jira_issue_transition_id, + :manual_configuration, + :merge_requests_events, + :mock_service_url, + :namespace, + :new_issue_url, + :notify_only_broken_pipelines, + :password, + :priority, + :project_key, + :project_name, + :project_url, + :recipients, + :restrict_to_branch, + :room, + :send_from_committer_email, + :server, + :server_host, + :server_port, + :sound, + :subdomain, + :teamcity_url, + :token, + :type, + :url, + :user_key, + :username, + :webhook + ].freeze + + # Parameters to ignore if no value is specified + FILTER_BLANK_PARAMS = [:password].freeze + + def integration_params + dynamic_params = @integration.event_channel_names + @integration.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables + allowed = allowed_integration_params + dynamic_params + return_value = params.permit(:id, integration: allowed, service: allowed) + return_value[:integration] ||= return_value.delete(:service) + param_values = return_value[:integration] + + if param_values.is_a?(ActionController::Parameters) + FILTER_BLANK_PARAMS.each do |param| + param_values.delete(param) if param_values[param].blank? + end + end + + return_value + end + + def allowed_integration_params + ALLOWED_PARAMS_CE + end + end +end + +Integrations::Params.prepend_mod_with('Integrations::Params') diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb index a3ea39d9c3d..f5a3ec913c2 100644 --- a/app/controllers/concerns/integrations_actions.rb +++ b/app/controllers/concerns/integrations_actions.rb @@ -4,7 +4,7 @@ module IntegrationsActions extend ActiveSupport::Concern included do - include ServiceParams + include Integrations::Params before_action :integration, only: [:edit, :update, :test] end @@ -14,7 +14,7 @@ module IntegrationsActions end def update - saved = integration.update(service_params[:service]) + saved = integration.update(integration_params[:integration]) respond_to do |format| format.html do @@ -49,9 +49,7 @@ module IntegrationsActions private def integration - # Using instance variable `@service` still required as it's used in ServiceParams. - # Should be removed once that is refactored to use `@integration`. - @integration = @service ||= find_or_initialize_non_project_specific_integration(params[:id]) # rubocop:disable Gitlab/ModuleWithInstanceVariables + @integration ||= find_or_initialize_non_project_specific_integration(params[:id]) end def success_message diff --git a/app/controllers/concerns/internal_redirect.rb b/app/controllers/concerns/internal_redirect.rb index a35bc19aa37..b803be67d2e 100644 --- a/app/controllers/concerns/internal_redirect.rb +++ b/app/controllers/concerns/internal_redirect.rb @@ -46,4 +46,4 @@ module InternalRedirect end end -InternalRedirect.prepend_if_ee('EE::InternalRedirect') +InternalRedirect.prepend_mod_with('InternalRedirect') diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 57d4203ad43..929e60a9e77 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -8,9 +8,6 @@ module IssuableActions before_action :authorize_destroy_issuable!, only: :destroy before_action :check_destroy_confirmation!, only: :destroy before_action :authorize_admin_issuable!, only: :bulk_update - before_action do - push_frontend_feature_flag(:not_issuable_queries, @project, default_enabled: true) - end end def show @@ -64,7 +61,7 @@ module IssuableActions end def destroy - Issuable::DestroyService.new(issuable.project, current_user).execute(issuable) + Issuable::DestroyService.new(project: issuable.project, current_user: current_user).execute(issuable) name = issuable.human_class_name flash[:notice] = "The #{name} was successfully deleted." @@ -262,4 +259,4 @@ module IssuableActions # rubocop:enable Gitlab/ModuleWithInstanceVariables end -IssuableActions.prepend_if_ee('EE::IssuableActions') +IssuableActions.prepend_mod_with('IssuableActions') diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 3f5f3b6e9df..d2d2e656af8 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -158,4 +158,4 @@ module IssuableCollections # rubocop:enable Gitlab/ModuleWithInstanceVariables end -IssuableCollections.prepend_if_ee('EE::IssuableCollections') +IssuableCollections.prepend_mod_with('IssuableCollections') diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb index 7ed66027da3..ca2979a5a29 100644 --- a/app/controllers/concerns/issuable_collections_action.rb +++ b/app/controllers/concerns/issuable_collections_action.rb @@ -32,10 +32,6 @@ module IssuableCollectionsAction private - def set_not_query_feature_flag(object = nil) - push_frontend_feature_flag(:not_issuable_queries, object, default_enabled: true) - end - def sorting_field case action_name when 'issues' diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index bc3fd32759f..55e0ed8cd42 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -136,4 +136,4 @@ module LfsRequest end end -LfsRequest.prepend_if_ee('EE::LfsRequest') +LfsRequest.prepend_mod_with('LfsRequest') diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb index 7bbee8ba79e..20861afbb88 100644 --- a/app/controllers/concerns/membership_actions.rb +++ b/app/controllers/concerns/membership_actions.rb @@ -186,3 +186,5 @@ module MembershipActions end end end + +MembershipActions.prepend_mod_with('MembershipActions') diff --git a/app/controllers/concerns/page_limiter.rb b/app/controllers/concerns/page_limiter.rb index 3c280fa4f12..362b02e5856 100644 --- a/app/controllers/concerns/page_limiter.rb +++ b/app/controllers/concerns/page_limiter.rb @@ -46,7 +46,7 @@ module PageLimiter if params[:page].present? && params[:page].to_i > max_page_number record_page_limit_interception - raise PageOutOfBoundsError.new(max_page_number) + raise PageOutOfBoundsError, max_page_number end end diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb index 4ea07c814ef..f1f5a1179c9 100644 --- a/app/controllers/concerns/renders_commits.rb +++ b/app/controllers/concerns/renders_commits.rb @@ -23,6 +23,7 @@ module RendersCommits def prepare_commits_for_rendering(commits) commits.each(&:lazy_author) # preload commits' authors + commits.each(&:lazy_latest_pipeline) Banzai::CommitRenderer.render(commits, @project, current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb index c92b1cecaaa..e98c1a30887 100644 --- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb +++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb @@ -35,6 +35,6 @@ module RequiresWhitelistedMonitoringClient end def render_404 - render file: Rails.root.join('public', '404'), layout: false, status: '404' + render "errors/not_found", layout: "errors", status: :not_found end end diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index bc2e7fba288..7257378f465 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -56,4 +56,4 @@ module RoutableActions end end -RoutableActions.prepend_if_ee('EE::RoutableActions') +RoutableActions.prepend_mod_with('RoutableActions') diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb deleted file mode 100644 index 7c57d321c80..00000000000 --- a/app/controllers/concerns/service_params.rb +++ /dev/null @@ -1,101 +0,0 @@ -# frozen_string_literal: true - -module ServiceParams - extend ActiveSupport::Concern - - ALLOWED_PARAMS_CE = [ - :active, - :add_pusher, - :alert_events, - :api_key, - :api_url, - :api_version, - :bamboo_url, - :branches_to_be_notified, - :labels_to_be_notified, - :build_key, - :build_type, - :ca_pem, - :channel, - :channels, - :color, - :colorize_messages, - :comment_on_event_enabled, - :comment_detail, - :confidential_issues_events, - :confluence_url, - :datadog_site, - :datadog_env, - :datadog_service, - :default_irc_uri, - :device, - :disable_diffs, - :drone_url, - :enable_ssl_verification, - :external_wiki_url, - :google_iap_service_account_json, - :google_iap_audience_client_id, - :inherit_from_id, - # We're using `issues_events` and `merge_requests_events` - # in the view so we still need to explicitly state them - # here. `Service#event_names` would only give - # `issue_events` and `merge_request_events` (singular!) - # See app/helpers/services_helper.rb for how we - # make those event names plural as special case. - :issues_events, - :issues_url, - :jenkins_url, - :jira_issue_transition_automatic, - :jira_issue_transition_id, - :manual_configuration, - :merge_requests_events, - :mock_service_url, - :namespace, - :new_issue_url, - :notify, - :notify_only_broken_pipelines, - :password, - :priority, - :project_key, - :project_name, - :project_url, - :recipients, - :restrict_to_branch, - :room, - :send_from_committer_email, - :server, - :server_host, - :server_port, - :sound, - :subdomain, - :teamcity_url, - :token, - :type, - :url, - :user_key, - :username, - :webhook - ].freeze - - # Parameters to ignore if no value is specified - FILTER_BLANK_PARAMS = [:password].freeze - - def service_params - dynamic_params = @service.event_channel_names + @service.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables - service_params = params.permit(:id, service: allowed_service_params + dynamic_params) - - if service_params[:service].is_a?(ActionController::Parameters) - FILTER_BLANK_PARAMS.each do |param| - service_params[:service].delete(param) if service_params[:service][param].blank? - end - end - - service_params - end - - def allowed_service_params - ALLOWED_PARAMS_CE - end -end - -ServiceParams.prepend_if_ee('EE::ServiceParams') diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index 60ff0a12d0c..fc4f9aa3409 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -115,9 +115,6 @@ module WikiActions @error = response.message render 'shared/wikis/edit' end - rescue WikiPage::PageChangedError, WikiPage::PageRenameError => e - @error = e.message - render 'shared/wikis/edit' end # rubocop:enable Gitlab/ModuleWithInstanceVariables @@ -141,8 +138,8 @@ module WikiActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def history if page - @page_versions = Kaminari.paginate_array(page.versions(page: params[:page].to_i), - total_count: page.count_versions) + @commits = Kaminari.paginate_array(page.versions(page: params[:page].to_i), + total_count: page.count_versions) .page(params[:page]) render 'shared/wikis/history' diff --git a/app/controllers/concerns/with_performance_bar.rb b/app/controllers/concerns/with_performance_bar.rb index 93ded59900d..dc2265e063a 100644 --- a/app/controllers/concerns/with_performance_bar.rb +++ b/app/controllers/concerns/with_performance_bar.rb @@ -20,12 +20,12 @@ module WithPerformanceBar end def cookie_or_default_value - return false unless Gitlab::PerformanceBar.enabled_for_user?(current_user) + cookie_enabled = if cookies[:perf_bar_enabled].present? + cookies[:perf_bar_enabled] == 'true' + else + cookies[:perf_bar_enabled] = 'true' if Rails.env.development? + end - if cookies[:perf_bar_enabled].present? - cookies[:perf_bar_enabled] == 'true' - else - cookies[:perf_bar_enabled] = 'true' if Rails.env.development? - end + cookie_enabled && Gitlab::PerformanceBar.allowed_for_user?(current_user) end end diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index c42c9827eaf..e82500912fa 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -27,7 +27,7 @@ class ConfirmationsController < Devise::ConfirmationsController else Gitlab::AppLogger.info("Email Confirmed: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip}") flash[:notice] = flash[:notice] + _(" Please sign in.") - new_session_path(:user, anchor: 'login-pane') + new_session_path(:user, anchor: 'login-pane', invite_email: resource.email) end end @@ -36,4 +36,4 @@ class ConfirmationsController < Devise::ConfirmationsController end end -ConfirmationsController.prepend_if_ee('EE::ConfirmationsController') +ConfirmationsController.prepend_mod_with('ConfirmationsController') diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index aa3592ff209..7cb39625371 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -116,4 +116,4 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController end end -Dashboard::ProjectsController.prepend_if_ee('EE::Dashboard::ProjectsController') +Dashboard::ProjectsController.prepend_mod_with('Dashboard::ProjectsController') diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 29cb60ad3cc..227dd0591d4 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -11,11 +11,10 @@ class DashboardController < Dashboard::ApplicationController before_action :projects, only: [:issues, :merge_requests] before_action :set_show_full_reference, only: [:issues, :merge_requests] before_action :check_filters_presence!, only: [:issues, :merge_requests] - before_action :set_not_query_feature_flag respond_to :html - feature_category :audit_events, [:activity] + feature_category :users, [:activity] feature_category :issue_tracking, [:issues, :issues_calendar] feature_category :code_review, [:merge_requests] diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index f6671f7250f..5ef973e9bf3 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -122,4 +122,4 @@ class Explore::ProjectsController < Explore::ApplicationController end end -Explore::ProjectsController.prepend_if_ee('EE::Explore::ProjectsController') +Explore::ProjectsController.prepend_mod_with('Explore::ProjectsController') diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 38bfb5ef2f8..725d8b62c77 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -109,6 +109,8 @@ class GraphqlController < ApplicationController end end + # When modifying the context, also update GraphqlChannel#context if needed + # so that we have similar context when executing queries, mutations, and subscriptions def context api_user = !!sessionless_user? @context ||= { diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index a504d2ce991..a3bbfc8be0d 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -72,4 +72,4 @@ class Groups::ApplicationController < ApplicationController end end -Groups::ApplicationController.prepend_if_ee('EE::Groups::ApplicationController') +Groups::ApplicationController.prepend_mod_with('Groups::ApplicationController') diff --git a/app/controllers/groups/autocomplete_sources_controller.rb b/app/controllers/groups/autocomplete_sources_controller.rb new file mode 100644 index 00000000000..5270a718952 --- /dev/null +++ b/app/controllers/groups/autocomplete_sources_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Groups::AutocompleteSourcesController < Groups::ApplicationController + feature_category :subgroups, [:members] + feature_category :issue_tracking, [:issues, :labels, :milestones, :commands] + feature_category :code_review, [:merge_requests] + + def members + render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target) + end + + def issues + render json: issuable_serializer.represent( + autocomplete_service.issues(confidential_only: params[:confidential_only], issue_types: params[:issue_types]), + parent_group: @group + ) + end + + def merge_requests + render json: issuable_serializer.represent(autocomplete_service.merge_requests, parent_group: @group) + end + + def labels + render json: autocomplete_service.labels_as_hash(target) + end + + def commands + render json: autocomplete_service.commands(target) + end + + def milestones + render json: autocomplete_service.milestones + end + + private + + def autocomplete_service + @autocomplete_service ||= ::Groups::AutocompleteService.new(@group, current_user, params) + end + + def issuable_serializer + GroupIssuableAutocompleteSerializer.new + end + + # rubocop: disable CodeReuse/ActiveRecord + def target + QuickActions::TargetService + .new(nil, current_user, group: @group) + .execute(params[:type], params[:type_id]) + end + # rubocop: enable CodeReuse/ActiveRecord +end + +Groups::AutocompleteSourcesController.prepend_mod diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index be38fe25842..e1f09d73739 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -5,7 +5,6 @@ class Groups::BoardsController < Groups::ApplicationController include RecordUserLastActivity include Gitlab::Utils::StrongMemoize - before_action :authorize_read_board!, only: [:index, :show] before_action :assign_endpoint_vars before_action do push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: false) diff --git a/app/controllers/groups/email_campaigns_controller.rb b/app/controllers/groups/email_campaigns_controller.rb index 4ce7d86be3c..c1e3ce519cc 100644 --- a/app/controllers/groups/email_campaigns_controller.rb +++ b/app/controllers/groups/email_campaigns_controller.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class Groups::EmailCampaignsController < Groups::ApplicationController - include InProductMarketingHelper - EMAIL_CAMPAIGNS_SCHEMA_URL = 'iglu:com.gitlab/email_campaigns/jsonschema/1-0-0' feature_category :navigation @@ -18,11 +16,13 @@ class Groups::EmailCampaignsController < Groups::ApplicationController def track_click if Gitlab.com? + message = Gitlab::Email::Message::InProductMarketing.for(@track).new(group: group, series: @series) + data = { namespace_id: group.id, track: @track.to_s, series: @series, - subject_line: subject_line(@track, @series) + subject_line: message.subject_line } context = SnowplowTracker::SelfDescribingJson.new(EMAIL_CAMPAIGNS_SCHEMA_URL, data) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 5df7ff0632a..c2ac56ccc63 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -4,6 +4,7 @@ class Groups::GroupMembersController < Groups::ApplicationController include MembershipActions include MembersPresentation include SortingHelper + include Gitlab::Utils::StrongMemoize MEMBER_PER_PAGE_LIMIT = 50 @@ -21,16 +22,17 @@ class Groups::GroupMembersController < Groups::ApplicationController feature_category :authentication_and_authorization + helper_method :can_manage_members? + def index + preload_max_access @sort = params[:sort].presence || sort_value_name - @project = @group.projects.find(params[:project_id]) if params[:project_id] - @members = GroupMembersFinder .new(@group, current_user, params: filter_params) .execute(include_relations: requested_relations) - if can_manage_members + if can_manage_members? @skip_groups = @group.related_group_ids @invited_members = @members.invite @@ -52,8 +54,18 @@ class Groups::GroupMembersController < Groups::ApplicationController private - def can_manage_members - can?(current_user, :admin_group_member, @group) + def preload_max_access + return unless current_user + + # this allows the can? against admin type queries in this action to + # only perform the query once, even if it is cached + current_user.max_access_for_group[@group.id] = @group.max_member_access(current_user) + end + + def can_manage_members? + strong_memoize(:can_manage_members) do + can?(current_user, :admin_group_member, @group) + end end def present_invited_members(invited_members) @@ -77,4 +89,4 @@ class Groups::GroupMembersController < Groups::ApplicationController end end -Groups::GroupMembersController.prepend_if_ee('EE::Groups::GroupMembersController') +Groups::GroupMembersController.prepend_mod_with('Groups::GroupMembersController') diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 84dc570a1e9..e9dce3947dd 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -21,7 +21,7 @@ class Groups::MilestonesController < Groups::ApplicationController end def new - @milestone = Milestone.new + @noteable = @milestone = Milestone.new end def create @@ -70,7 +70,7 @@ class Groups::MilestonesController < Groups::ApplicationController end def milestone - @milestone = group.milestones.find_by_iid(params[:id]) + @noteable = @milestone ||= group.milestones.find_by_iid(params[:id]) render_404 unless @milestone end @@ -95,4 +95,4 @@ class Groups::MilestonesController < Groups::ApplicationController end end -Groups::MilestonesController.prepend_if_ee('EE::Groups::MilestonesController') +Groups::MilestonesController.prepend_mod_with('Groups::MilestonesController') diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index dbfd31ebcad..b02b0e85d38 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -10,7 +10,6 @@ class Groups::RunnersController < Groups::ApplicationController feature_category :continuous_integration def show - render 'shared/runners/show' end def edit diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index f1a6bcbe825..88c709e3f53 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -100,4 +100,4 @@ module Groups end end -Groups::Settings::CiCdController.prepend_if_ee('EE::Groups::Settings::CiCdController') +Groups::Settings::CiCdController.prepend_mod_with('Groups::Settings::CiCdController') diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb index c3c93fe238a..8e3b2cb5d1b 100644 --- a/app/controllers/groups/settings/integrations_controller.rb +++ b/app/controllers/groups/settings/integrations_controller.rb @@ -12,11 +12,11 @@ module Groups layout 'group_settings' def index - @integrations = Service.find_or_initialize_all_non_project_specific(Service.for_group(group)).sort_by(&:title) + @integrations = Integration.find_or_initialize_all_non_project_specific(Integration.for_group(group)).sort_by(&:title) end def edit - @default_integration = Service.default_integration(integration.type, group) + @default_integration = Integration.default_integration(integration.type, group) super end @@ -24,7 +24,7 @@ module Groups private def find_or_initialize_non_project_specific_integration(name) - Service.find_or_initialize_non_project_specific_integration(name, group_id: group.id) + Integration.find_or_initialize_non_project_specific_integration(name, group_id: group.id) end def scoped_edit_integration_path(integration) diff --git a/app/controllers/groups/settings/packages_and_registries_controller.rb b/app/controllers/groups/settings/packages_and_registries_controller.rb index 90fb6497e20..c44e0727ff9 100644 --- a/app/controllers/groups/settings/packages_and_registries_controller.rb +++ b/app/controllers/groups/settings/packages_and_registries_controller.rb @@ -9,7 +9,7 @@ module Groups feature_category :package_registry - def index + def show end private diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 75bb6975c6e..00ddb8d736c 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -57,4 +57,4 @@ module Groups end end -Groups::VariablesController.prepend_if_ee('EE::Groups::VariablesController') +Groups::VariablesController.prepend_mod_with('Groups::VariablesController') diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 63f138aa462..a755d242d4a 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -35,10 +35,6 @@ class GroupsController < Groups::ApplicationController push_frontend_feature_flag(:vue_issuables_list, @group) end - before_action do - set_not_query_feature_flag(@group) - end - before_action :export_rate_limit, only: [:export, :download_export] helper_method :captcha_required? @@ -53,10 +49,9 @@ class GroupsController < Groups::ApplicationController feature_category :subgroups, [ :index, :new, :create, :show, :edit, :update, - :destroy, :details, :transfer + :destroy, :details, :transfer, :activity ] - feature_category :audit_events, [:activity] feature_category :issue_tracking, [:issues, :issues_calendar, :preview_markdown] feature_category :code_review, [:merge_requests, :unfoldered_environment_names] feature_category :projects, [:projects] @@ -197,7 +192,7 @@ class GroupsController < Groups::ApplicationController def unfoldered_environment_names respond_to do |format| format.json do - render json: EnvironmentNamesFinder.new(@group, current_user).execute + render json: Environments::EnvironmentNamesFinder.new(@group, current_user).execute end end end @@ -369,4 +364,4 @@ class GroupsController < Groups::ApplicationController end end -GroupsController.prepend_if_ee('EE::GroupsController') +GroupsController.prepend_mod_with('GroupsController') diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index 87cda723895..1121ecfb65c 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -97,7 +97,7 @@ class Import::BaseController < ApplicationController group = Groups::NestedCreateService.new(current_user, group_path: names).execute group.errors.any? ? current_user.namespace : group - rescue => e + rescue StandardError => e Gitlab::AppLogger.error(e) current_user.namespace diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb index 17f937a3dfd..9f91f3a1e1c 100644 --- a/app/controllers/import/fogbugz_controller.rb +++ b/app/controllers/import/fogbugz_controller.rb @@ -15,7 +15,7 @@ class Import::FogbugzController < Import::BaseController def callback begin res = Gitlab::FogbugzImport::Client.new(import_params.to_h.symbolize_keys) - rescue + rescue StandardError # If the URI is invalid various errors can occur return redirect_to new_import_fogbugz_path, alert: _('Could not connect to FogBugz, check your URL') end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index beb3e92b5ea..22bcd14d664 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -265,4 +265,4 @@ class Import::GithubController < Import::BaseController end end -Import::GithubController.prepend_if_ee('EE::Import::GithubController') +Import::GithubController.prepend_mod_with('Import::GithubController') diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 0eb08d2d0ad..0a9a9e03e94 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -3,10 +3,10 @@ class InvitesController < ApplicationController include Gitlab::Utils::StrongMemoize + prepend_before_action :authenticate_user!, :track_invite_join_click, only: :show before_action :member before_action :ensure_member_exists before_action :invite_details - before_action :set_invite_type, only: :show skip_before_action :authenticate_user!, only: :decline helper_method :member?, :current_user_matches_invite? @@ -16,18 +16,12 @@ class InvitesController < ApplicationController feature_category :authentication_and_authorization def show - experiment('members/invite_email', actor: member).track(:opened) if initial_invite_email? - accept if skip_invitation_prompt? end def accept if member.accept_invite!(current_user) - experiment('members/invite_email', actor: member).track(:accepted) if initial_invite_email? - session.delete(:invite_type) - - redirect_to invite_details[:path], notice: _("You have been granted %{member_human_access} access to %{title} %{name}.") % - { member_human_access: member.human_access, title: invite_details[:title], name: invite_details[:name] } + redirect_to invite_details[:path], notice: helpers.invite_accepted_notice(member) else redirect_back_or_default(options: { alert: _("The invitation could not be accepted.") }) end @@ -53,14 +47,6 @@ class InvitesController < ApplicationController private - def set_invite_type - session[:invite_type] = params[:invite_type] if params[:invite_type].in?([Members::InviteEmailExperiment::INVITE_TYPE]) - end - - def initial_invite_email? - session[:invite_type] == Members::InviteEmailExperiment::INVITE_TYPE - end - def skip_invitation_prompt? !member? && current_user_matches_invite? end @@ -85,21 +71,48 @@ class InvitesController < ApplicationController def ensure_member_exists return if member - render_404 + redirect_back_or_default(options: { alert: _("The invitation can not be found with the provided invite token.") }) + end + + def track_invite_join_click + experiment('members/invite_email', actor: member).track(:join_clicked) if member && Members::InviteEmailExperiment.initial_invite_email?(params[:invite_type]) end def authenticate_user! return if current_user - store_location_for :user, request.fullpath + store_location_for(:user, invite_landing_url) if member if user_sign_up? - redirect_to new_user_registration_path(invite_email: member.invite_email), notice: _("To accept this invitation, create an account or sign in.") + set_session_invite_params + + experiment(:invite_signup_page_interaction, actor: member) do |experiment_instance| + set_originating_member_id if experiment_instance.enabled? + + experiment_instance.use do + redirect_to new_user_registration_path(invite_email: member.invite_email), notice: _("To accept this invitation, create an account or sign in.") + end + experiment_instance.try do + redirect_to new_users_sign_up_invite_path(invite_email: member.invite_email) + end + + experiment_instance.track(:view) + end else redirect_to new_user_session_path(sign_in_redirect_params), notice: sign_in_notice end end + def set_session_invite_params + session[:invite_email] = member.invite_email + + set_originating_member_id if Members::InviteEmailExperiment.initial_invite_email?(params[:invite_type]) + end + + def set_originating_member_id + session[:originating_member_id] = member.id + end + def sign_in_redirect_params member ? { invite_email: member.invite_email } : {} end @@ -116,6 +129,10 @@ class InvitesController < ApplicationController end end + def invite_landing_url + root_url + invite_details[:path] + end + def invite_details @invite_details ||= case member.source when Project @@ -123,14 +140,14 @@ class InvitesController < ApplicationController name: member.source.full_name, url: project_url(member.source), title: _("project"), - path: project_path(member.source) + path: member.source.activity_path } when Group { name: member.source.name, url: group_url(member.source), title: _("group"), - path: group_path(member.source) + path: member.source.activity_path } end end diff --git a/app/controllers/jira_connect/application_controller.rb b/app/controllers/jira_connect/application_controller.rb index 9c311f92b69..a6529ecb4ce 100644 --- a/app/controllers/jira_connect/application_controller.rb +++ b/app/controllers/jira_connect/application_controller.rb @@ -24,7 +24,7 @@ class JiraConnect::ApplicationController < ApplicationController # Make sure `qsh` claim matches the current request render_403 unless payload['qsh'] == Atlassian::Jwt.create_query_string_hash(request.url, request.method, jira_connect_base_url) - rescue + rescue StandardError render_403 end diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb index ebc35448964..6aa46b8e4c3 100644 --- a/app/controllers/ldap/omniauth_callbacks_controller.rb +++ b/app/controllers/ldap/omniauth_callbacks_controller.rb @@ -38,4 +38,4 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController end end -Ldap::OmniauthCallbacksController.prepend_if_ee('EE::Ldap::OmniauthCallbacksController') +Ldap::OmniauthCallbacksController.prepend_mod_with('Ldap::OmniauthCallbacksController') diff --git a/app/controllers/oauth/jira/authorizations_controller.rb b/app/controllers/oauth/jira/authorizations_controller.rb index f23149c8544..8169b5fcbb0 100644 --- a/app/controllers/oauth/jira/authorizations_controller.rb +++ b/app/controllers/oauth/jira/authorizations_controller.rb @@ -16,7 +16,7 @@ class Oauth::Jira::AuthorizationsController < ApplicationController redirect_to oauth_authorization_path(client_id: params['client_id'], response_type: 'code', - scope: params['scope'], + scope: normalize_scope(params['scope']), redirect_uri: oauth_jira_callback_url) end @@ -48,4 +48,12 @@ class Oauth::Jira::AuthorizationsController < ApplicationController rescue Doorkeeper::Errors::DoorkeeperError => e render status: :unauthorized, body: e.type end + + private + + # When using the GitHub Enterprise connector in Jira we receive the "repo" scope, + # this doesn't exist in GitLab but we can map it to our "api" scope. + def normalize_scope(scope) + scope == 'repo' ? 'api' : scope + end end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index af502c083d7..31f404a9974 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -289,4 +289,4 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController end end -OmniauthCallbacksController.prepend_if_ee('EE::OmniauthCallbacksController') +OmniauthCallbacksController.prepend_mod_with('OmniauthCallbacksController') diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index bc6975f8953..2c0ed825daa 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -69,4 +69,4 @@ class PasswordsController < Devise::PasswordsController end end -PasswordsController.prepend_if_ee('EE::PasswordsController') +PasswordsController.prepend_mod_with('PasswordsController') diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb index d8419be9f23..bd52ef0b0d4 100644 --- a/app/controllers/profiles/accounts_controller.rb +++ b/app/controllers/profiles/accounts_controller.rb @@ -37,4 +37,4 @@ class Profiles::AccountsController < Profiles::ApplicationController end end -Profiles::AccountsController.prepend_if_ee('EE::Profiles::AccountsController') +Profiles::AccountsController.prepend_mod_with('Profiles::AccountsController') diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 251967a7dff..ba539ef808d 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -60,4 +60,4 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController end end -Profiles::PersonalAccessTokensController.prepend_if_ee('EE::Profiles::PersonalAccessTokensController') +Profiles::PersonalAccessTokensController.prepend_mod_with('Profiles::PersonalAccessTokensController') diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 45bab5f6cd1..adecb56ea38 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -55,4 +55,4 @@ class Profiles::PreferencesController < Profiles::ApplicationController end end -Profiles::PreferencesController.prepend_if_ee('::EE::Profiles::PreferencesController') +Profiles::PreferencesController.prepend_mod_with('Profiles::PreferencesController') diff --git a/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb b/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb new file mode 100644 index 00000000000..7b4f6739a9b --- /dev/null +++ b/app/controllers/projects/analytics/cycle_analytics/stages_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class Projects::Analytics::CycleAnalytics::StagesController < Projects::ApplicationController + respond_to :json + + feature_category :planning_analytics + + before_action :authorize_read_cycle_analytics! + before_action :only_default_value_stream_is_allowed! + + def index + result = list_service.execute + + if result.success? + render json: cycle_analytics_configuration(result.payload[:stages]) + else + render json: { message: result.message }, status: result.http_status + end + end + + private + + def only_default_value_stream_is_allowed! + render_404 if params[:value_stream_id] != Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME + end + + def value_stream + Analytics::CycleAnalytics::ProjectValueStream.build_default_value_stream(@project) + end + + def list_params + { value_stream: value_stream } + end + + def list_service + Analytics::CycleAnalytics::Stages::ListService.new(parent: @project, current_user: current_user, params: list_params) + end + + def cycle_analytics_configuration(stages) + stage_presenters = stages.map { |s| ::Analytics::CycleAnalytics::StagePresenter.new(s) } + + Analytics::CycleAnalytics::ConfigurationEntity.new(stages: stage_presenters) + end +end diff --git a/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb b/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb new file mode 100644 index 00000000000..03dcb164d94 --- /dev/null +++ b/app/controllers/projects/analytics/cycle_analytics/value_streams_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Projects::Analytics::CycleAnalytics::ValueStreamsController < Projects::ApplicationController + respond_to :json + + feature_category :planning_analytics + + before_action :authorize_read_cycle_analytics! + + def index + # FOSS users can only see the default value stream + value_streams = [Analytics::CycleAnalytics::ProjectValueStream.build_default_value_stream(@project)] + + render json: Analytics::CycleAnalytics::ValueStreamSerializer.new.represent(value_streams) + end +end diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 001967b8bb4..7c419cac1cc 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -49,4 +49,4 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController end end -Projects::AutocompleteSourcesController.prepend_if_ee('EE::Projects::AutocompleteSourcesController') +Projects::AutocompleteSourcesController.prepend_mod_with('Projects::AutocompleteSourcesController') diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index 2c7c49b4250..1df7b9ed165 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -20,7 +20,7 @@ class Projects::BlameController < Projects::ApplicationController environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } environment_params[:find_latest] = true - @environment = EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last + @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last @blame = Gitlab::Blame.new(@blob, @commit) @blame = Gitlab::View::Presenter::Factory.new(@blame, project: @project, path: @path).fabricate! diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index a398fc56a35..dbe628cb43a 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -31,19 +31,23 @@ class Projects::BlobController < Projects::ApplicationController before_action :editor_variables, except: [:show, :preview, :diff] before_action :validate_diff_params, only: :diff before_action :set_last_commit_sha, only: [:edit, :update] - before_action :record_experiment, only: :new + before_action :track_experiment, only: :create track_redis_hll_event :create, :update, name: 'g_edit_by_sfe' feature_category :source_code_management + before_action do + push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) + end + def new commit unless @repository.empty? end def create create_commit(Files::CreateService, success_notice: _("The file has been successfully created."), - success_path: -> { project_blob_path(@project, File.join(@branch_name, @file_path)) }, + success_path: -> { create_success_path }, failure_view: :new, failure_path: project_new_blob_path(@project, @ref)) end @@ -214,7 +218,7 @@ class Projects::BlobController < Projects::ApplicationController def show_html environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } environment_params[:find_latest] = true - @environment = EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last + @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last @last_commit = @repository.last_commit_for_path(@commit.id, @blob.path, literal_pathspec: true) @code_navigation_path = Gitlab::CodeNavigationPath.new(@project, @blob.commit_id).full_json_path_for(@blob.path) @@ -262,9 +266,17 @@ class Projects::BlobController < Projects::ApplicationController current_user&.id end - def record_experiment - return unless params[:file_name] == @project.ci_config_path_or_default && @project.namespace.recent? + def create_success_path + if params[:code_quality_walkthrough] + project_pipelines_path(@project, code_quality_walkthrough: true) + else + project_blob_path(@project, File.join(@branch_name, @file_path)) + end + end + + def track_experiment + return unless params[:code_quality_walkthrough] - record_experiment_user(:ci_syntax_templates_b, namespace_id: @project.namespace_id) + experiment(:code_quality_walkthrough, namespace: @project.root_ancestor).track(:commit_created) end end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 349649c7b35..9a3e9437426 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -5,7 +5,6 @@ class Projects::BoardsController < Projects::ApplicationController include IssuableCollections before_action :check_issues_available! - before_action :authorize_read_board!, only: [:index, :show] before_action :assign_endpoint_vars before_action do push_frontend_feature_flag(:swimlanes_buffered_rendering, project, default_enabled: :yaml) diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 754e2ccf4f9..6e31816bc99 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -3,11 +3,9 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController before_action :check_can_collaborate! before_action do - push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: :yaml) - push_frontend_feature_flag(:ci_config_merged_tab, @project, default_enabled: :yaml) - push_frontend_feature_flag(:pipeline_status_for_pipeline_editor, @project, default_enabled: :yaml) push_frontend_feature_flag(:pipeline_editor_empty_state_action, @project, default_enabled: :yaml) push_frontend_feature_flag(:pipeline_editor_branch_switcher, @project, default_enabled: :yaml) + push_frontend_feature_flag(:pipeline_editor_drawer, @project, default_enabled: :yaml) end feature_category :pipeline_authoring diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 0c3ff07bc76..863715429ff 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -24,7 +24,7 @@ class Projects::CommitController < Projects::ApplicationController end BRANCH_SEARCH_LIMIT = 1000 - COMMIT_DIFFS_PER_PAGE = 75 + COMMIT_DIFFS_PER_PAGE = 20 feature_category :source_code_management @@ -49,7 +49,7 @@ class Projects::CommitController < Projects::ApplicationController end def diff_files - render json: { html: view_to_html_string('projects/commit/diff_files', diffs: @diffs, environment: @environment) } + render template: 'projects/commit/diff_files', layout: false, locals: { diffs: @diffs, environment: @environment } end # rubocop: disable CodeReuse/ActiveRecord @@ -167,7 +167,7 @@ class Projects::CommitController < Projects::ApplicationController @diffs = commit.diffs(opts) @notes_count = commit.notes.count - @environment = EnvironmentsByDeploymentsFinder.new(@project, current_user, commit: @commit, find_latest: true).execute.last + @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, commit: @commit, find_latest: true).execute.last end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 221bc16e256..28a87f83451 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -26,6 +26,10 @@ class Projects::CompareController < Projects::ApplicationController feature_category :source_code_management + # Diffs may be pretty chunky, the less is better in this endpoint. + # Pagination design guides: https://design.gitlab.com/components/pagination/#behavior + COMMIT_DIFFS_PER_PAGE = 20 + def index end @@ -132,7 +136,7 @@ class Projects::CompareController < Projects::ApplicationController if compare environment_params = source_project.repository.branch_exists?(head_ref) ? { ref: head_ref } : { commit: compare.commit } environment_params[:find_latest] = true - @environment = EnvironmentsByDeploymentsFinder.new(source_project, current_user, environment_params).execute.last + @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(source_project, current_user, environment_params).execute.last end end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 92483607e65..76de9a83c87 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -264,4 +264,4 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end -Projects::EnvironmentsController.prepend_if_ee('EE::Projects::EnvironmentsController') +Projects::EnvironmentsController.prepend_mod_with('Projects::EnvironmentsController') diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 9fc8e8c063b..8fa3635a737 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -131,4 +131,4 @@ class Projects::ForksController < Projects::ApplicationController end end -Projects::ForksController.prepend_if_ee('EE::Projects::ForksController') +Projects::ForksController.prepend_mod_with('Projects::ForksController') diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index c6c90ffaba2..27893fe510d 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -60,4 +60,4 @@ class Projects::GroupLinksController < Projects::ApplicationController end end -Projects::GroupLinksController.prepend_if_ee('EE::Projects::GroupLinksController') +Projects::GroupLinksController.prepend_mod_with('Projects::GroupLinksController') diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index 8dabf3e640b..b87bfc58f8b 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -32,6 +32,7 @@ class Projects::HooksController < Projects::ApplicationController end def edit + redirect_to(action: :index) unless hook end def update diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index c8528ad6d28..3b3f9bdcf6b 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -81,4 +81,4 @@ class Projects::ImportsController < Projects::ApplicationController end end -Projects::ImportsController.prepend_if_ee('EE::Projects::ImportsController') +Projects::ImportsController.prepend_mod_with('Projects::ImportsController') diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index cae5cc411bc..01a6de76ba5 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -56,8 +56,6 @@ class Projects::IssuesController < Projects::ApplicationController push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml) push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml) - record_experiment_user(:invite_members_version_b) - experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance| experiment_instance.exclude! unless helpers.can_import_members? @@ -110,12 +108,12 @@ class Projects::IssuesController < Projects::ApplicationController params[:issue] ||= ActionController::Parameters.new( assignee_ids: "" ) - build_params = issue_create_params.merge( + build_params = issue_params.merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], discussion_to_resolve: params[:discussion_to_resolve], - confidential: !!Gitlab::Utils.to_boolean(issue_create_params[:confidential]) + confidential: !!Gitlab::Utils.to_boolean(issue_params[:confidential]) ) - service = ::Issues::BuildService.new(project, current_user, build_params) + service = ::Issues::BuildService.new(project: project, current_user: current_user, params: build_params) @issue = @noteable = service.execute @@ -130,12 +128,12 @@ class Projects::IssuesController < Projects::ApplicationController end def create - create_params = issue_create_params.merge(spammable_params).merge( + create_params = issue_params.merge(spammable_params).merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], discussion_to_resolve: params[:discussion_to_resolve] ) - service = ::Issues::CreateService.new(project, current_user, create_params) + service = ::Issues::CreateService.new(project: project, current_user: current_user, params: create_params) @issue = service.execute create_vulnerability_issue_feedback(issue) @@ -162,7 +160,7 @@ class Projects::IssuesController < Projects::ApplicationController new_project = Project.find(params[:move_to_project_id]) return render_404 unless issue.can_move?(current_user, new_project) - @issue = ::Issues::UpdateService.new(project, current_user, target_project: new_project).execute(issue) + @issue = ::Issues::UpdateService.new(project: project, current_user: current_user, params: { target_project: new_project }).execute(issue) end respond_to do |format| @@ -176,7 +174,7 @@ class Projects::IssuesController < Projects::ApplicationController end def reorder - service = ::Issues::ReorderService.new(project, current_user, reorder_params) + service = ::Issues::ReorderService.new(project: project, current_user: current_user, params: reorder_params) if service.execute(issue) head :ok @@ -187,7 +185,7 @@ class Projects::IssuesController < Projects::ApplicationController def related_branches @related_branches = ::Issues::RelatedBranchesService - .new(project, current_user) + .new(project: project, current_user: current_user) .execute(issue) .map { |branch| branch.merge(link: branch_link(branch)) } @@ -215,7 +213,7 @@ class Projects::IssuesController < Projects::ApplicationController def create_merge_request create_params = params.slice(:branch_name, :ref).merge(issue_iid: issue.iid) create_params[:target_project_id] = params[:target_project_id] - result = ::MergeRequests::CreateFromIssueService.new(project, current_user, create_params).execute + result = ::MergeRequests::CreateFromIssueService.new(project: project, current_user: current_user, mr_params: create_params).execute if result[:status] == :success render json: MergeRequestCreateSerializer.new.represent(result[:merge_request]) @@ -316,17 +314,8 @@ class Projects::IssuesController < Projects::ApplicationController task_num lock_version discussion_locked - ] + [{ label_ids: [], assignee_ids: [], update_task: [:index, :checked, :line_number, :line_source] }] - end - - def issue_create_params - create_params = %i[ issue_type - ] - - params.require(:issue).permit( - *create_params - ).merge(issue_params) + ] + [{ label_ids: [], assignee_ids: [], update_task: [:index, :checked, :line_number, :line_source] }] end def reorder_params @@ -345,7 +334,7 @@ class Projects::IssuesController < Projects::ApplicationController def update_service update_params = issue_params.merge(spammable_params) - ::Issues::UpdateService.new(project, current_user, update_params) + ::Issues::UpdateService.new(project: project, current_user: current_user, params: update_params) end def finder_type @@ -402,4 +391,4 @@ class Projects::IssuesController < Projects::ApplicationController def create_vulnerability_issue_feedback(issue); end end -Projects::IssuesController.prepend_if_ee('EE::Projects::IssuesController') +Projects::IssuesController.prepend_mod_with('Projects::IssuesController') diff --git a/app/controllers/projects/logs_controller.rb b/app/controllers/projects/logs_controller.rb index f9b8091a419..a4bdbc827e0 100644 --- a/app/controllers/projects/logs_controller.rb +++ b/app/controllers/projects/logs_controller.rb @@ -58,7 +58,7 @@ module Projects def environment strong_memoize(:environment) do if cluster_params.key?(:environment_name) - EnvironmentsFinder.new(project, current_user, name: cluster_params[:environment_name]).execute.first + ::Environments::EnvironmentsFinder.new(project, current_user, name: cluster_params[:environment_name]).execute.first else project.default_environment end diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index e74717a44ab..78170fab7a7 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -65,4 +65,4 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont end end -Projects::MergeRequests::ApplicationController.prepend_if_ee('EE::Projects::MergeRequests::ApplicationController') +Projects::MergeRequests::ApplicationController.prepend_mod_with('Projects::MergeRequests::ApplicationController') diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index dc77b5e09c8..9f1e2d8236a 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -19,7 +19,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap end def create - @merge_request = ::MergeRequests::CreateService.new(project, current_user, merge_request_params).execute + @merge_request = ::MergeRequests::CreateService.new(project: project, current_user: current_user, params: merge_request_params).execute if @merge_request.valid? incr_count_webide_merge_request @@ -93,7 +93,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap # Gitaly N+1 issue: https://gitlab.com/gitlab-org/gitlab-foss/issues/58096 Gitlab::GitalyClient.allow_n_plus_1_calls do - @merge_request = ::MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute + @merge_request = ::MergeRequests::BuildService.new(project: project, current_user: current_user, params: merge_request_params.merge(diff_options: diff_options)).execute end end @@ -141,4 +141,4 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap end end -Projects::MergeRequests::CreationsController.prepend_ee_mod +Projects::MergeRequests::CreationsController.prepend_mod diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 98ef9d918ae..3eaabfbf33e 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -47,7 +47,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic diffs = @compare.diffs(diff_options) render json: DiffsMetadataSerializer.new(project: @merge_request.project, current_user: current_user) - .represent(diffs, additional_attributes) + .represent(diffs, additional_attributes.merge(only_context_commits: show_only_context_commits?)) end private @@ -92,7 +92,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic # rubocop: disable CodeReuse/ActiveRecord def commit return unless commit_id = params[:commit_id].presence - return unless @merge_request.all_commits.exists?(sha: commit_id) + return unless @merge_request.all_commits.exists?(sha: commit_id) || @merge_request.recent_context_commits.map(&:id).include?(commit_id) @commit ||= @project.commit(commit_id) end @@ -122,6 +122,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic end end + return @merge_request.context_commits_diff if show_only_context_commits? && !@merge_request.context_commits_diff.empty? return @merge_request.merge_head_diff if render_merge_ref_head_diff? if @start_sha diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 4e409b5f28f..613faa200d1 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -42,11 +42,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml) push_frontend_feature_flag(:usage_data_i_testing_summary_widget_total, @project, default_enabled: :yaml) push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml) + push_frontend_feature_flag(:diffs_virtual_scrolling, project, default_enabled: :yaml) # Usage data feature flags push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml) - - record_experiment_user(:invite_members_version_b) + push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml) experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance| experiment_instance.exclude! unless helpers.can_import_members? @@ -60,6 +60,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action do push_frontend_feature_flag(:mr_collapsed_approval_rules, @project) + push_frontend_feature_flag(:show_relevant_approval_rule_approvers, @project, default_enabled: :yaml) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] @@ -114,6 +115,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @noteable = @merge_request @commits_count = @merge_request.commits_count + @merge_request.context_commits_count + @diffs_count = get_diffs_count @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') @current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestCurrentUserEntity).to_json @show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs @@ -244,7 +246,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def update - @merge_request = ::MergeRequests::UpdateService.new(project, current_user, merge_request_update_params).execute(@merge_request) + @merge_request = ::MergeRequests::UpdateService.new(project: project, current_user: current_user, params: merge_request_update_params).execute(@merge_request) respond_to do |format| format.html do @@ -273,7 +275,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def remove_wip @merge_request = ::MergeRequests::UpdateService - .new(project, current_user, wip_event: 'unwip') + .new(project: project, current_user: current_user, params: { wip_event: 'unwip' }) .execute(@merge_request) render json: serialize_widget(@merge_request) @@ -308,7 +310,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def assign_related_issues - result = ::MergeRequests::AssignIssuesService.new(project, current_user, merge_request: @merge_request).execute + result = ::MergeRequests::AssignIssuesService.new(project: project, current_user: current_user, params: { merge_request: @merge_request }).execute case result[:count] when 0 @@ -386,6 +388,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo private + def get_diffs_count + if show_only_context_commits? + @merge_request.context_commits_diff.raw_diffs.size + else + @merge_request.diff_size + end + end + def merge_request_update_params merge_request_params.merge!(params.permit(:merge_request_diff_head_sha)) end @@ -412,7 +422,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo return :failed end - merge_service = ::MergeRequests::MergeService.new(@project, current_user, merge_params) + merge_service = ::MergeRequests::MergeService.new(project: @project, current_user: current_user, params: merge_params) unless merge_service.hooks_validation_pass?(@merge_request) return :hook_validation_error @@ -525,4 +535,4 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end end -Projects::MergeRequestsController.prepend_if_ee('EE::Projects::MergeRequestsController') +Projects::MergeRequestsController.prepend_mod_with('Projects::MergeRequestsController') diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index dcd3c49441e..dcdda18784d 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -39,7 +39,7 @@ class Projects::MilestonesController < Projects::ApplicationController end def new - @milestone = @project.milestones.new + @noteable = @milestone = @project.milestones.new respond_with(@milestone) end @@ -125,7 +125,7 @@ class Projects::MilestonesController < Projects::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def milestone - @milestone ||= @project.milestones.find_by!(iid: params[:id]) + @noteable = @milestone ||= @project.milestones.find_by!(iid: params[:id]) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb index 01abb72fc86..bcb6b574d5a 100644 --- a/app/controllers/projects/mirrors_controller.rb +++ b/app/controllers/projects/mirrors_controller.rb @@ -94,4 +94,4 @@ class Projects::MirrorsController < Projects::ApplicationController end end -Projects::MirrorsController.prepend_if_ee('EE::Projects::MirrorsController') +Projects::MirrorsController.prepend_mod_with('Projects::MirrorsController') diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index 0aac517e3e3..4bd33882eee 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -55,4 +55,4 @@ class Projects::PagesController < Projects::ApplicationController end end -Projects::PagesController.prepend_if_ee('EE::Projects::PagesController') +Projects::PagesController.prepend_mod_with('Projects::PagesController') diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 9f326ef59f5..0de8dc597ae 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -13,14 +13,12 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_create_pipeline!, only: [:new, :create, :config_variables] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action do - push_frontend_feature_flag(:new_pipeline_form, project, default_enabled: :yaml) push_frontend_feature_flag(:pipeline_graph_layers_view, project, type: :development, default_enabled: :yaml) push_frontend_feature_flag(:pipeline_filter_jobs, project, default_enabled: :yaml) push_frontend_feature_flag(:graphql_pipeline_details, project, type: :development, default_enabled: :yaml) push_frontend_feature_flag(:graphql_pipeline_details_users, current_user, type: :development, default_enabled: :yaml) - push_frontend_feature_flag(:jira_for_vulnerabilities, project, type: :development, default_enabled: :yaml) end - before_action :ensure_pipeline, only: [:show] + before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? } @@ -33,7 +31,12 @@ class Projects::PipelinesController < Projects::ApplicationController POLLING_INTERVAL = 10_000 - feature_category :continuous_integration + feature_category :continuous_integration, [ + :charts, :show, :config_variables, :stage, :cancel, :retry, + :builds, :dag, :failures, :status, :downloadable_artifacts, + :index, :create, :new, :destroy + ] + feature_category :code_testing, [:test_report] def index @pipelines = Ci::PipelinesFinder @@ -55,6 +58,17 @@ class Projects::PipelinesController < Projects::ApplicationController e.try {} e.track(:view, value: project.namespace_id) end + experiment(:code_quality_walkthrough, namespace: project.root_ancestor) do |e| + e.exclude! unless current_user + e.exclude! unless can?(current_user, :create_pipeline, project) + e.exclude! unless project.root_ancestor.recent? + e.exclude! if @pipelines_count.to_i > 0 + e.exclude! if helpers.has_gitlab_ci?(project) + + e.use {} + e.try {} + e.track(:view, property: project.root_ancestor.id.to_s) + end end format.json do Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) @@ -162,7 +176,11 @@ class Projects::PipelinesController < Projects::ApplicationController end def retry - pipeline.retry_failed(current_user) + if Gitlab::Ci::Features.background_pipeline_retry_endpoint?(@project) + ::Ci::RetryPipelineWorker.perform_async(pipeline.id, current_user.id) # rubocop:disable CodeReuse/Worker + else + pipeline.retry_failed(current_user) + end respond_to do |format| format.html do @@ -206,13 +224,20 @@ class Projects::PipelinesController < Projects::ApplicationController end end + def downloadable_artifacts + render json: Ci::DownloadableArtifactSerializer.new( + project: project, + current_user: current_user + ).represent(@pipeline) + end + private def serialize_pipelines PipelineSerializer .new(project: @project, current_user: @current_user) .with_pagination(request, response) - .represent(@pipelines, disable_coverage: true, preload: true) + .represent(@pipelines, disable_coverage: true, preload: true, code_quality_walkthrough: params[:code_quality_walkthrough].present?) end def render_show @@ -298,4 +323,4 @@ class Projects::PipelinesController < Projects::ApplicationController end end -Projects::PipelinesController.prepend_if_ee('EE::Projects::PipelinesController') +Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController') diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 5972b29a298..cc2157a7d51 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -64,4 +64,4 @@ class Projects::ProjectMembersController < Projects::ApplicationController end end -Projects::ProjectMembersController.prepend_if_ee('EE::Projects::ProjectMembersController') +Projects::ProjectMembersController.prepend_mod_with('Projects::ProjectMembersController') diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb index 84b155c8002..8c70ef446a2 100644 --- a/app/controllers/projects/protected_branches_controller.rb +++ b/app/controllers/projects/protected_branches_controller.rb @@ -29,4 +29,4 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController end end -Projects::ProtectedBranchesController.prepend_if_ee('EE::Projects::ProtectedBranchesController') +Projects::ProtectedBranchesController.prepend_mod_with('Projects::ProtectedBranchesController') diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb index 4cba1a75330..abbfe9ce22a 100644 --- a/app/controllers/projects/protected_refs_controller.rb +++ b/app/controllers/projects/protected_refs_controller.rb @@ -68,4 +68,4 @@ class Projects::ProtectedRefsController < Projects::ApplicationController end end -Projects::ProtectedRefsController.prepend_if_ee('EE::Projects::ProtectedRefsController') +Projects::ProtectedRefsController.prepend_mod_with('Projects::ProtectedRefsController') diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 26382856761..1bb50eabd1d 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -8,11 +8,6 @@ class Projects::ReleasesController < Projects::ApplicationController # We have to check `download_code` permission because detail URL path # contains git-tag name. before_action :authorize_download_code!, except: [:index] - before_action do - push_frontend_feature_flag(:graphql_release_data, project, default_enabled: true) - push_frontend_feature_flag(:graphql_milestone_stats, project, default_enabled: true) - push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: true) - end before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_create_release!, only: :new diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index da018b24836..8f64a8aa1d3 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -35,7 +35,7 @@ class Projects::RepositoriesController < Projects::ApplicationController return if archive_not_modified? send_git_archive @repository, **repo_params - rescue => ex + rescue StandardError => ex logger.error("#{self.class.name}: #{ex}") git_not_found! end @@ -127,4 +127,4 @@ class Projects::RepositoriesController < Projects::ApplicationController end end -Projects::RepositoriesController.prepend_if_ee('EE::Projects::RepositoriesController') +Projects::RepositoriesController.prepend_mod_with('Projects::RepositoriesController') diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb index d225d5e104c..fa6adc9431d 100644 --- a/app/controllers/projects/runner_projects_controller.rb +++ b/app/controllers/projects/runner_projects_controller.rb @@ -17,7 +17,10 @@ class Projects::RunnerProjectsController < Projects::ApplicationController if @runner.assign_to(project, current_user) redirect_to path else - redirect_to path, alert: 'Failed adding runner to project' + assign_to_messages = @runner.errors.messages[:assign_to] + alert = assign_to_messages&.join(',') || 'Failed adding runner to project' + + redirect_to path, alert: alert end end diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb index b7a5a63e642..ec1f57f090a 100644 --- a/app/controllers/projects/runners_controller.rb +++ b/app/controllers/projects/runners_controller.rb @@ -48,28 +48,17 @@ class Projects::RunnersController < Projects::ApplicationController end def show - render 'shared/runners/show' end def toggle_shared_runners if !project.shared_runners_enabled && project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable' - - if Feature.enabled?(:vueify_shared_runners_toggle, @project) - render json: { error: _('Cannot enable shared runners because parent group does not allow it') }, status: :unauthorized - else - redirect_to project_runners_path(@project), alert: _('Cannot enable shared runners because parent group does not allow it') - end - + render json: { error: _('Cannot enable shared runners because parent group does not allow it') }, status: :unauthorized return end project.toggle!(:shared_runners_enabled) - if Feature.enabled?(:vueify_shared_runners_toggle, @project) - render json: {}, status: :ok - else - redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings') - end + render json: {}, status: :ok end def toggle_group_runners diff --git a/app/controllers/projects/security/configuration_controller.rb b/app/controllers/projects/security/configuration_controller.rb index bc4e58e54a9..19de157357a 100644 --- a/app/controllers/projects/security/configuration_controller.rb +++ b/app/controllers/projects/security/configuration_controller.rb @@ -14,4 +14,4 @@ module Projects end end -Projects::Security::ConfigurationController.prepend_if_ee('EE::Projects::Security::ConfigurationController') +Projects::Security::ConfigurationController.prepend_mod_with('Projects::Security::ConfigurationController') diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index ccb8b393bfe..74145a70b95 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -1,19 +1,16 @@ # frozen_string_literal: true class Projects::ServicesController < Projects::ApplicationController - include ServiceParams + include Integrations::Params include InternalRedirect # Authorize before_action :authorize_admin_project! before_action :ensure_service_enabled - before_action :service + before_action :integration before_action :web_hook_logs, only: [:edit, :update] before_action :set_deprecation_notice_for_prometheus_service, only: [:edit, :update] before_action :redirect_deprecated_prometheus_service, only: [:update] - before_action only: :edit do - push_frontend_feature_flag(:jira_for_vulnerabilities, @project, type: :development, default_enabled: :yaml) - end respond_to :html @@ -22,20 +19,19 @@ class Projects::ServicesController < Projects::ApplicationController feature_category :integrations def edit - @default_integration = Service.default_integration(service.type, project) + @default_integration = Integration.default_integration(service.type, project) end def update - @service.attributes = service_params[:service] - @service.inherit_from_id = nil if service_params[:service][:inherit_from_id].blank? + @integration.attributes = integration_params[:integration] + @integration.inherit_from_id = nil if integration_params[:integration][:inherit_from_id].blank? - saved = @service.save(context: :manual_change) + saved = @integration.save(context: :manual_change) respond_to do |format| format.html do if saved - target_url = safe_redirect_path(params[:redirect_to]).presence || edit_project_service_path(@project, @service) - redirect_to target_url, notice: success_message + redirect_to redirect_path, notice: success_message else render 'edit' end @@ -50,7 +46,7 @@ class Projects::ServicesController < Projects::ApplicationController end def test - if @service.can_test? + if integration.can_test? render json: service_test_response, status: :ok else render json: {}, status: :not_found @@ -59,12 +55,16 @@ class Projects::ServicesController < Projects::ApplicationController private + def redirect_path + safe_redirect_path(params[:redirect_to]).presence || edit_project_service_path(@project, @integration) + end + def service_test_response - unless @service.update(service_params[:service]) - return { error: true, message: _('Validations failed.'), service_response: @service.errors.full_messages.join(','), test_failed: false } + unless @integration.update(integration_params[:integration]) + return { error: true, message: _('Validations failed.'), service_response: @integration.errors.full_messages.join(','), test_failed: false } end - result = ::Integrations::Test::ProjectService.new(@service, current_user, params[:event]).execute + result = ::Integrations::Test::ProjectService.new(@integration, current_user, params[:event]).execute unless result[:success] return { error: true, message: s_('Integrations|Connection failed. Please check your settings.'), service_response: result[:message].to_s, test_failed: true } @@ -76,16 +76,18 @@ class Projects::ServicesController < Projects::ApplicationController end def success_message - if @service.active? - s_('Integrations|%{integration} settings saved and active.') % { integration: @service.title } + if integration.active? + s_('Integrations|%{integration} settings saved and active.') % { integration: integration.title } else - s_('Integrations|%{integration} settings saved, but not active.') % { integration: @service.title } + s_('Integrations|%{integration} settings saved, but not active.') % { integration: integration.title } end end - def service - @service ||= @project.find_or_initialize_service(params[:id]) + def integration + @integration ||= @project.find_or_initialize_service(params[:id]) + @service ||= @integration # TODO: remove references to @service end + alias_method :service, :integration def web_hook_logs return unless @service.service_hook.present? @@ -98,17 +100,17 @@ class Projects::ServicesController < Projects::ApplicationController end def serialize_as_json - @service + integration .as_json(only: @service.json_fields) .merge(errors: @service.errors.as_json) end def redirect_deprecated_prometheus_service - redirect_to edit_project_service_path(project, @service) if @service.is_a?(::PrometheusService) && Feature.enabled?(:settings_operations_prometheus_service, project) + redirect_to edit_project_service_path(project, integration) if integration.is_a?(::PrometheusService) && Feature.enabled?(:settings_operations_prometheus_service, project) end def set_deprecation_notice_for_prometheus_service - return if !@service.is_a?(::PrometheusService) || !Feature.enabled?(:settings_operations_prometheus_service, project) + return if !integration.is_a?(::PrometheusService) || !Feature.enabled?(:settings_operations_prometheus_service, project) operations_link_start = "<a href=\"#{project_settings_operations_path(project)}\">" message = s_('PrometheusService|You can now manage your Prometheus settings on the %{operations_link_start}Operations%{operations_link_end} page. Fields on this page has been deprecated.') % { operations_link_start: operations_link_start, operations_link_end: "</a>" } diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 1a465406660..3254d4129d3 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -12,7 +12,6 @@ module Projects before_action :define_variables before_action do push_frontend_feature_flag(:ajax_new_deploy_token, @project) - push_frontend_feature_flag(:vueify_shared_runners_toggle, @project) end helper_method :highlight_badge @@ -119,12 +118,13 @@ module Projects .assignable_for(project) .ordered .page(params[:specific_page]).per(NUMBER_OF_RUNNERS_PER_PAGE) + .with_tags - @shared_runners = ::Ci::Runner.instance_type.active + @shared_runners = ::Ci::Runner.instance_type.active.with_tags @shared_runners_count = @shared_runners.count(:all) - @group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id) + @group_runners = ::Ci::Runner.belonging_to_parent_group_of_project(@project.id).with_tags end def define_ci_variables @@ -143,7 +143,7 @@ module Projects end def define_badges_variables - @ref = params[:ref] || @project.default_branch || 'master' + @ref = params[:ref] || @project.default_branch_or_main @badges = [Gitlab::Ci::Badge::Pipeline::Status, Gitlab::Ci::Badge::Coverage::Report] @@ -160,4 +160,4 @@ module Projects end end -Projects::Settings::CiCdController.prepend_if_ee('EE::Projects::Settings::CiCdController') +Projects::Settings::CiCdController.prepend_mod_with('Projects::Settings::CiCdController') diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index a05793a0283..a357227c870 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -155,4 +155,4 @@ module Projects end end -Projects::Settings::OperationsController.prepend_if_ee('::EE::Projects::Settings::OperationsController') +Projects::Settings::OperationsController.prepend_mod_with('Projects::Settings::OperationsController') diff --git a/app/controllers/projects/settings/packages_and_registries_controller.rb b/app/controllers/projects/settings/packages_and_registries_controller.rb new file mode 100644 index 00000000000..fee51dc1311 --- /dev/null +++ b/app/controllers/projects/settings/packages_and_registries_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Projects + module Settings + class PackagesAndRegistriesController < Projects::ApplicationController + layout 'project_settings' + + before_action :authorize_admin_project! + before_action :packages_and_registries_settings_enabled! + + feature_category :package_registry + + def show + end + + private + + def packages_and_registries_settings_enabled! + render_404 unless settings_packages_and_registries_enabled?(project) + end + end + end +end diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb index bb5ad8e9aea..728231dbdbd 100644 --- a/app/controllers/projects/settings/repository_controller.rb +++ b/app/controllers/projects/settings/repository_controller.rb @@ -134,4 +134,4 @@ module Projects end end -Projects::Settings::RepositoryController.prepend_if_ee('EE::Projects::Settings::RepositoryController') +Projects::Settings::RepositoryController.prepend_mod_with('Projects::Settings::RepositoryController') diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index ff28c3be298..de2ab16b5b1 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -13,6 +13,10 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController before_action :authorize_read_snippet!, except: [:new, :index] before_action :authorize_update_snippet!, only: :edit + before_action only: [:show] do + push_frontend_feature_flag(:improved_emoji_picker, @project, default_enabled: :yaml) + end + def index @snippet_counts = ::Snippets::CountService .new(current_user, project: @project) diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 3bf9988ca22..94b0473e1f3 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -9,9 +9,6 @@ class Projects::TagsController < Projects::ApplicationController before_action :require_non_empty_project before_action :authorize_download_code! before_action :authorize_admin_tag!, only: [:new, :create, :destroy] - before_action do - push_frontend_feature_flag(:gldropdown_tags, default_enabled: :yaml) - end feature_category :source_code_management, [:index, :show, :new, :destroy] feature_category :release_evidence, [:create] diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index d1486f765e4..a1493a25a1a 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -6,4 +6,8 @@ class Projects::WikisController < Projects::ApplicationController alias_method :container, :project feature_category :wiki + + before_action do + push_frontend_feature_flag(:wiki_content_editor, project, default_enabled: :yaml) + end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 7c9d6daad02..e66893ac269 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -43,13 +43,12 @@ class ProjectsController < Projects::ApplicationController feature_category :projects, [ :index, :show, :new, :create, :edit, :update, :transfer, - :destroy, :resolve, :archive, :unarchive, :toggle_star + :destroy, :resolve, :archive, :unarchive, :toggle_star, :activity ] feature_category :source_code_management, [:remove_fork, :housekeeping, :refs] feature_category :issue_tracking, [:preview_markdown, :new_issuable_address] feature_category :importers, [:export, :remove_export, :generate_new_export, :download_export] - feature_category :audit_events, [:activity] feature_category :code_review, [:unfoldered_environment_names] def index @@ -85,7 +84,7 @@ class ProjectsController < Projects::ApplicationController notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name } ) else - render 'new', locals: { active_tab: active_new_project_tab } + render 'new' end end @@ -311,7 +310,7 @@ class ProjectsController < Projects::ApplicationController def unfoldered_environment_names respond_to do |format| format.json do - render json: EnvironmentNamesFinder.new(@project, current_user).execute + render json: Environments::EnvironmentNamesFinder.new(@project, current_user).execute end end end @@ -545,4 +544,4 @@ class ProjectsController < Projects::ApplicationController end end -ProjectsController.prepend_if_ee('EE::ProjectsController') +ProjectsController.prepend_mod_with('ProjectsController') diff --git a/app/controllers/registrations/experience_levels_controller.rb b/app/controllers/registrations/experience_levels_controller.rb index 3a721823d89..d04e8d296ed 100644 --- a/app/controllers/registrations/experience_levels_controller.rb +++ b/app/controllers/registrations/experience_levels_controller.rb @@ -38,7 +38,7 @@ module Registrations end def learn_gitlab - @learn_gitlab ||= LearnGitlab.new(current_user) + @learn_gitlab ||= LearnGitlab::Project.new(current_user) end end end diff --git a/app/controllers/registrations/invites_controller.rb b/app/controllers/registrations/invites_controller.rb new file mode 100644 index 00000000000..548714e80e9 --- /dev/null +++ b/app/controllers/registrations/invites_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Registrations + class InvitesController < RegistrationsController + layout 'simple_registration' + + before_action :check_if_gl_com_or_dev + end +end diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index 62ec03206c4..87465f8714d 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -18,7 +18,13 @@ module Registrations if result[:status] == :success return redirect_to new_users_sign_up_group_path if show_signup_onboarding? - redirect_to path_for_signed_in_user(current_user) + members = current_user.members + + if members.count == 1 && members.last.source.present? + redirect_to members_activity_path(members), notice: helpers.invite_accepted_notice(members.last) + else + redirect_to path_for_signed_in_user(current_user) + end else render :show end @@ -48,7 +54,14 @@ module Registrations def path_for_signed_in_user(user) return users_almost_there_path if requires_confirmation?(user) - stored_location_for(user) || dashboard_projects_path + stored_location_for(user) || members_activity_path(user.members) + end + + def members_activity_path(members) + return dashboard_projects_path unless members.any? + return dashboard_projects_path unless members.last.source.present? + + members.last.source.activity_path end def show_signup_onboarding? @@ -57,4 +70,4 @@ module Registrations end end -Registrations::WelcomeController.prepend_if_ee('EE::Registrations::WelcomeController') +Registrations::WelcomeController.prepend_mod_with('Registrations::WelcomeController') diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 61218a95add..0f29f6f608f 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -155,13 +155,21 @@ class RegistrationsController < Devise::RegistrationsController end def resource - @resource ||= Users::BuildService.new(current_user, sign_up_params).execute + @resource ||= Users::RegistrationsBuildService + .new(current_user, sign_up_params.merge({ skip_confirmation: skip_email_confirmation? })) + .execute end def devise_mapping @devise_mapping ||= Devise.mappings[:user] end + def skip_email_confirmation? + invite_email = session.delete(:invite_email) + + sign_up_params[:email] == invite_email + end + def load_recaptcha Gitlab::Recaptcha.load_configurations! end @@ -179,6 +187,21 @@ class RegistrationsController < Devise::RegistrationsController def set_invite_params @invite_email = ActionController::Base.helpers.sanitize(params[:invite_email]) end + + def after_pending_invitations_hook + member_id = session.delete(:originating_member_id) + + return unless member_id + + # if invited multiple times to different projects, only the email clicked will be counted as accepted + # for the specific member on a project or group + member = resource.members.find_by(id: member_id) # rubocop: disable CodeReuse/ActiveRecord + + return unless member + + experiment(:invite_signup_page_interaction, actor: member).track(:form_submission) + experiment('members/invite_email', actor: member).track(:accepted) + end end -RegistrationsController.prepend_if_ee('EE::RegistrationsController') +RegistrationsController.prepend_mod_with('RegistrationsController') diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb index a5b81054ee4..76d9983d341 100644 --- a/app/controllers/repositories/git_http_client_controller.rb +++ b/app/controllers/repositories/git_http_client_controller.rb @@ -134,4 +134,4 @@ module Repositories end end -Repositories::GitHttpClientController.prepend_if_ee('EE::Repositories::GitHttpClientController') +Repositories::GitHttpClientController.prepend_mod_with('Repositories::GitHttpClientController') diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb index d68ba80ab5d..11a219b4ff0 100644 --- a/app/controllers/repositories/git_http_controller.rb +++ b/app/controllers/repositories/git_http_controller.rb @@ -122,4 +122,4 @@ module Repositories end end -Repositories::GitHttpController.prepend_if_ee('EE::Repositories::GitHttpController') +Repositories::GitHttpController.prepend_mod_with('Repositories::GitHttpController') diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb index 2de29da4b45..4f2e02c78c3 100644 --- a/app/controllers/repositories/lfs_api_controller.rb +++ b/app/controllers/repositories/lfs_api_controller.rb @@ -148,4 +148,4 @@ module Repositories end end -Repositories::LfsApiController.prepend_if_ee('EE::Repositories::LfsApiController') +Repositories::LfsApiController.prepend_mod_with('Repositories::LfsApiController') diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb index 672a03ad11d..97b6671a82a 100644 --- a/app/controllers/root_controller.rb +++ b/app/controllers/root_controller.rb @@ -70,4 +70,4 @@ class RootController < Dashboard::ProjectsController end end -RootController.prepend_if_ee('EE::RootController') +RootController.prepend_mod_with('RootController') diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 3b218822395..ac6239615b4 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -152,4 +152,4 @@ class SearchController < ApplicationController end end -SearchController.prepend_if_ee('EE::SearchController') +SearchController.prepend_mod_with('SearchController') diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb index db07b212d00..64d66ee86f1 100644 --- a/app/controllers/sent_notifications_controller.rb +++ b/app/controllers/sent_notifications_controller.rb @@ -53,4 +53,4 @@ class SentNotificationsController < ApplicationController end end -SentNotificationsController.prepend_if_ee('EE::SentNotificationsController') +SentNotificationsController.prepend_mod_with('SentNotificationsController') diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index b8842b2efdb..4fcf82c605b 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -22,6 +22,7 @@ class SessionsController < Devise::SessionsController prepend_before_action :check_captcha, only: [:create] prepend_before_action :store_redirect_uri, only: [:new] prepend_before_action :require_no_authentication_without_flash, only: [:new, :create] + prepend_before_action :check_forbidden_password_based_login, if: -> { action_name == 'create' && password_based_login? } prepend_before_action :ensure_password_authentication_enabled!, if: -> { action_name == 'create' && password_based_login? } before_action :auto_sign_in_with_provider, only: [:new] @@ -313,6 +314,13 @@ class SessionsController < Devise::SessionsController def set_invite_params @invite_email = ActionController::Base.helpers.sanitize(params[:invite_email]) end + + def check_forbidden_password_based_login + if find_user&.password_based_login_forbidden? + flash[:alert] = _('You are not allowed to log in using password') + redirect_to new_user_session_path + end + end end -SessionsController.prepend_if_ee('EE::SessionsController') +SessionsController.prepend_mod_with('SessionsController') diff --git a/app/controllers/terraform/services_controller.rb b/app/controllers/terraform/services_controller.rb new file mode 100644 index 00000000000..e7b9a94fd8e --- /dev/null +++ b/app/controllers/terraform/services_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Terraform::ServicesController < ApplicationController + skip_before_action :authenticate_user! + + feature_category :infrastructure_as_code + + def index + render json: { 'modules.v1' => "/api/#{::API::API.version}/packages/terraform/modules/v1/" } + end +end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 2c827292928..4077a3d3dac 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -117,4 +117,4 @@ class UploadsController < ApplicationController end end -UploadsController.prepend_if_ee('EE::UploadsController') +UploadsController.prepend_mod_with('UploadsController') diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 54d97f588fc..287ee2d5ab8 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -260,4 +260,4 @@ class UsersController < ApplicationController end end -UsersController.prepend_if_ee('EE::UsersController') +UsersController.prepend_mod_with('UsersController') diff --git a/app/controllers/whats_new_controller.rb b/app/controllers/whats_new_controller.rb index e24b0bbc7bb..6f389aa4924 100644 --- a/app/controllers/whats_new_controller.rb +++ b/app/controllers/whats_new_controller.rb @@ -5,6 +5,7 @@ class WhatsNewController < ApplicationController skip_before_action :authenticate_user! + before_action :check_whats_new_enabled before_action :check_valid_page_param, :set_pagination_headers feature_category :navigation @@ -19,6 +20,10 @@ class WhatsNewController < ApplicationController private + def check_whats_new_enabled + render_404 if Gitlab::CurrentSettings.current_application_settings.whats_new_variant_disabled? + end + def check_valid_page_param render_404 if current_page < 1 end diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb index 01105f6cec4..d7c4d2fcda3 100644 --- a/app/experiments/application_experiment.rb +++ b/app/experiments/application_experiment.rb @@ -36,6 +36,10 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp @excluded = true end + def control_behavior + # define a default nil control behavior so we can omit it when not needed + end + private def feature_flag_name diff --git a/app/experiments/concerns/project_commit_count.rb b/app/experiments/concerns/project_commit_count.rb new file mode 100644 index 00000000000..706a1a24640 --- /dev/null +++ b/app/experiments/concerns/project_commit_count.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ProjectCommitCount + include Gitlab::Git::WrapsGitalyErrors + + def commit_count_for(project, default_count: 0, max_count: nil, **exception_details) + raw_repo = project.repository&.raw_repository + root_ref = raw_repo&.root_ref + + return default_count unless root_ref + + Gitlab::GitalyClient::CommitService.new(raw_repo).commit_count(root_ref, { + all: true, # include all branches + max_count: max_count # limit as an optimization + }) + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, exception_details) + + default_count + end +end diff --git a/app/experiments/empty_repo_upload_experiment.rb b/app/experiments/empty_repo_upload_experiment.rb new file mode 100644 index 00000000000..d0d79a5fb45 --- /dev/null +++ b/app/experiments/empty_repo_upload_experiment.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class EmptyRepoUploadExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass + include ProjectCommitCount + + TRACKING_START_DATE = DateTime.parse('2021/4/20') + INITIAL_COMMIT_COUNT = 1 + + def track_initial_write + return unless should_track? # early return if we don't need to ask for commit counts + return unless context.project.created_at > TRACKING_START_DATE # early return for older projects + return unless commit_count == INITIAL_COMMIT_COUNT + + track(:initial_write, project: context.project) + end + + private + + def commit_count + commit_count_for(context.project, max_count: INITIAL_COMMIT_COUNT, experiment: name) + end +end diff --git a/app/experiments/in_product_guidance_environments_webide_experiment.rb b/app/experiments/in_product_guidance_environments_webide_experiment.rb new file mode 100644 index 00000000000..d77063a9834 --- /dev/null +++ b/app/experiments/in_product_guidance_environments_webide_experiment.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class InProductGuidanceEnvironmentsWebideExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass + exclude :has_environments? + + def control_behavior + false + end + + private + + def has_environments? + !context.project.environments.empty? + end +end diff --git a/app/experiments/members/invite_email_experiment.rb b/app/experiments/members/invite_email_experiment.rb index 6a7d2b110d3..f780c6962df 100644 --- a/app/experiments/members/invite_email_experiment.rb +++ b/app/experiments/members/invite_email_experiment.rb @@ -7,6 +7,10 @@ module Members INVITE_TYPE = 'initial_email' + def self.initial_invite_email?(invite_type) + invite_type == INVITE_TYPE + end + def resolve_variant_name RoundRobin.new(feature_flag_name, %i[avatar permission_info control]).execute end diff --git a/app/experiments/new_project_readme_experiment.rb b/app/experiments/new_project_readme_experiment.rb index 8f88ad2adc1..c5c41330949 100644 --- a/app/experiments/new_project_readme_experiment.rb +++ b/app/experiments/new_project_readme_experiment.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class NewProjectReadmeExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass - include Gitlab::Git::WrapsGitalyErrors + include ProjectCommitCount INITIAL_WRITE_LIMIT = 3 EXPERIMENT_START_DATE = DateTime.parse('2021/1/20') @@ -21,25 +21,18 @@ class NewProjectReadmeExperiment < ApplicationExperiment # rubocop:disable Gitla def track_initial_writes(project) return unless should_track? # early return if we don't need to ask for commit counts return unless project.created_at > EXPERIMENT_START_DATE # early return for older projects - return unless (commit_count = commit_count_for(project)) < INITIAL_WRITE_LIMIT + return unless (count = commit_count(project)) < INITIAL_WRITE_LIMIT - track(:write, property: project.created_at.to_s, value: commit_count) + track(:write, property: project.created_at.to_s, value: count) end private - def commit_count_for(project) - raw_repo = project.repository&.raw_repository - return INITIAL_WRITE_LIMIT unless raw_repo&.root_ref - - begin - Gitlab::GitalyClient::CommitService.new(raw_repo).commit_count(raw_repo.root_ref, { - all: true, # include all branches - max_count: INITIAL_WRITE_LIMIT # limit as an optimization - }) - rescue StandardError => e - Gitlab::ErrorTracking.track_exception(e, experiment: name) - INITIAL_WRITE_LIMIT - end + def commit_count(project) + commit_count_for(project, + default_count: INITIAL_WRITE_LIMIT, + max_count: INITIAL_WRITE_LIMIT, + experiment: name + ) end end diff --git a/app/finders/alert_management/alerts_finder.rb b/app/finders/alert_management/alerts_finder.rb index 8e0444d324a..b4f66a38faa 100644 --- a/app/finders/alert_management/alerts_finder.rb +++ b/app/finders/alert_management/alerts_finder.rb @@ -67,4 +67,4 @@ module AlertManagement end end -AlertManagement::AlertsFinder.prepend_if_ee('EE::AlertManagement::AlertsFinder') +AlertManagement::AlertsFinder.prepend_mod_with('AlertManagement::AlertsFinder') diff --git a/app/finders/alert_management/http_integrations_finder.rb b/app/finders/alert_management/http_integrations_finder.rb index 5d4c9b6fbe3..e8e85da11b7 100644 --- a/app/finders/alert_management/http_integrations_finder.rb +++ b/app/finders/alert_management/http_integrations_finder.rb @@ -51,4 +51,4 @@ module AlertManagement end end -::AlertManagement::HttpIntegrationsFinder.prepend_if_ee('EE::AlertManagement::HttpIntegrationsFinder') +::AlertManagement::HttpIntegrationsFinder.prepend_mod_with('AlertManagement::HttpIntegrationsFinder') diff --git a/app/finders/analytics/cycle_analytics/stage_finder.rb b/app/finders/analytics/cycle_analytics/stage_finder.rb new file mode 100644 index 00000000000..732e9ff3e00 --- /dev/null +++ b/app/finders/analytics/cycle_analytics/stage_finder.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class StageFinder + def initialize(parent:, stage_id:) + @parent = parent + @stage_id = stage_id + end + + def execute + build_in_memory_stage_by_name + end + + private + + attr_reader :parent, :stage_id + + def build_in_memory_stage_by_name + parent.cycle_analytics_stages.build(find_in_memory_stage) + end + + def find_in_memory_stage + # raise ActiveRecord::RecordNotFound, so it will behave similarly to AR models and produce 404 response in the controller + raw_stage = Gitlab::Analytics::CycleAnalytics::DefaultStages.all.find do |hash| + hash[:name].eql?(stage_id) + end + + raise(ActiveRecord::RecordNotFound, "Stage with id '#{stage_id}' could not be found") unless raw_stage + + raw_stage + end + end + end +end + +Analytics::CycleAnalytics::StageFinder.prepend_mod_with('Analytics::CycleAnalytics::StageFinder') diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb index ff5d9ea7d19..a9fffd3f411 100644 --- a/app/finders/autocomplete/users_finder.rb +++ b/app/finders/autocomplete/users_finder.rb @@ -102,4 +102,4 @@ module Autocomplete end end -Autocomplete::UsersFinder.prepend_if_ee('EE::Autocomplete::UsersFinder') +Autocomplete::UsersFinder.prepend_mod_with('Autocomplete::UsersFinder') diff --git a/app/finders/ci/daily_build_group_report_results_finder.rb b/app/finders/ci/daily_build_group_report_results_finder.rb index 5ac1bbd0670..33aefe29392 100644 --- a/app/finders/ci/daily_build_group_report_results_finder.rb +++ b/app/finders/ci/daily_build_group_report_results_finder.rb @@ -94,4 +94,4 @@ module Ci end end -Ci::DailyBuildGroupReportResultsFinder.prepend_if_ee('::EE::Ci::DailyBuildGroupReportResultsFinder') +Ci::DailyBuildGroupReportResultsFinder.prepend_mod_with('Ci::DailyBuildGroupReportResultsFinder') diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb index e509cf940b8..af7b23278a4 100644 --- a/app/finders/ci/pipelines_finder.rb +++ b/app/finders/ci/pipelines_finder.rb @@ -133,7 +133,7 @@ module Ci when true items.where.not(yaml_errors: nil) when false - items.where("yaml_errors IS NULL") + items.where(yaml_errors: nil) else items end diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb index 1f6ee9d75ad..be65b1f6b3c 100644 --- a/app/finders/ci/pipelines_for_merge_request_finder.rb +++ b/app/finders/ci/pipelines_for_merge_request_finder.rb @@ -45,8 +45,12 @@ module Ci private + # rubocop: disable CodeReuse/ActiveRecord def pipelines_using_cte - cte = Gitlab::SQL::CTE.new(:shas, merge_request.all_commits.select(:sha)) + sha_relation = merge_request.all_commits.select(:sha) + sha_relation = sha_relation.distinct if Feature.enabled?(:use_distinct_in_shas_cte) + + cte = Gitlab::SQL::CTE.new(:shas, sha_relation) pipelines_for_merge_requests = triggered_by_merge_request pipelines_for_branch = filter_by_sha(triggered_for_branch, cte) @@ -54,6 +58,7 @@ module Ci Ci::Pipeline.with(cte.to_arel) # rubocop: disable CodeReuse/ActiveRecord .from_union([pipelines_for_merge_requests, pipelines_for_branch]) end + # rubocop: enable CodeReuse/ActiveRecord def filter_by_sha(pipelines, cte) hex = Arel::Nodes::SqlLiteral.new("'hex'") diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 1b76211c524..60dd977ff94 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -4,8 +4,6 @@ module Ci class RunnersFinder < UnionFinder include Gitlab::Allowable - NUMBER_OF_RUNNERS_PER_PAGE = 30 - def initialize(current_user:, group: nil, params:) @params = params @group = group @@ -18,7 +16,6 @@ module Ci filter_by_runner_type! filter_by_tag_list! sort! - paginate! @runners.with_tags @@ -77,10 +74,6 @@ module Ci @runners = @runners.order_by(sort_key) end - def paginate! - @runners = @runners.page(@params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE) - end - def filter_by!(scope_name, available_scopes) scope = @params[scope_name] diff --git a/app/finders/concerns/packages/finder_helper.rb b/app/finders/concerns/packages/finder_helper.rb index 39c018818d1..f0ad998cadb 100644 --- a/app/finders/concerns/packages/finder_helper.rb +++ b/app/finders/concerns/packages/finder_helper.rb @@ -9,12 +9,16 @@ module Packages private + def packages_for_project(project) + project.packages.installable + end + def packages_visible_to_user(user, within_group:) return ::Packages::Package.none unless within_group return ::Packages::Package.none unless Ability.allowed?(user, :read_group, within_group) projects = projects_visible_to_reporters(user, within_group: within_group) - ::Packages::Package.for_projects(projects.select(:id)) + ::Packages::Package.for_projects(projects.select(:id)).installable end def projects_visible_to_user(user, within_group:) @@ -25,7 +29,7 @@ module Packages end def projects_visible_to_reporters(user, within_group:) - if user.is_a?(DeployToken) && Feature.enabled?(:packages_finder_helper_deploy_token) + if user.is_a?(DeployToken) && Feature.enabled?(:packages_finder_helper_deploy_token, default_enabled: :yaml) user.accessible_projects else within_group.all_projects @@ -38,7 +42,7 @@ module Packages end def filter_by_package_type(packages) - return packages unless package_type + return packages.without_package_type(:terraform_module) unless package_type raise InvalidPackageTypeError unless ::Packages::Package.package_types.key?(package_type) packages.with_package_type(package_type) @@ -50,6 +54,12 @@ module Packages packages.search_by_name(params[:package_name]) end + def filter_by_package_version(packages) + return packages unless params[:package_version].present? + + packages.with_version(params[:package_version]) + end + def filter_with_version(packages) return packages if params[:include_versionless].present? diff --git a/app/finders/deploy_tokens/tokens_finder.rb b/app/finders/deploy_tokens/tokens_finder.rb new file mode 100644 index 00000000000..98456628375 --- /dev/null +++ b/app/finders/deploy_tokens/tokens_finder.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Arguments: +# current_user: The currently logged in user. +# scope: A Project or Group to scope deploy tokens to (or :all for all tokens). +# params: +# active: Boolean - When true, only return active deployments. +module DeployTokens + class TokensFinder + attr_reader :current_user, :params, :scope + + def initialize(current_user, scope, params = {}) + @current_user = current_user + @scope = scope + @params = params + end + + def execute + by_active(init_collection) + end + + private + + def init_collection + case scope + when Group, Project + raise Gitlab::Access::AccessDeniedError unless current_user.can?(:read_deploy_token, scope) + + scope.deploy_tokens + when :all + raise Gitlab::Access::AccessDeniedError unless current_user.can_read_all_resources? + + DeployToken.all + else + raise ArgumentError, "Scope must be a Group, a Project, or the :all symbol." + end + end + + def by_active(items) + params[:active] ? items.active : items + end + end +end diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb index ae26fc14ad5..acce038dba6 100644 --- a/app/finders/deployments_finder.rb +++ b/app/finders/deployments_finder.rb @@ -16,14 +16,25 @@ class DeploymentsFinder attr_reader :params + # Warning: + # These const are directly used in Deployment Rest API, thus + # modifying these values could implicity change the API interface or introduce a breaking change. + # Also, if you add a sort value, make sure that the new query will stay + # performant with the other filtering/sorting parameters. + # The composed query could be significantly slower when the filtering and sorting columns are different. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/325627 for example. ALLOWED_SORT_VALUES = %w[id iid created_at updated_at ref finished_at].freeze DEFAULT_SORT_VALUE = 'id' ALLOWED_SORT_DIRECTIONS = %w[asc desc].freeze DEFAULT_SORT_DIRECTION = 'asc' + InefficientQueryError = Class.new(StandardError) + def initialize(params = {}) @params = params + + validate! end def execute @@ -38,15 +49,45 @@ class DeploymentsFinder private + def validate! + if filter_by_updated_at? && filter_by_finished_at? + raise InefficientQueryError, 'Both `updated_at` filter and `finished_at` filter can not be specified' + end + + # Currently, the inefficient parameters are allowed in order to avoid breaking changes in Deployment API. + # We'll switch to a hard error in https://gitlab.com/gitlab-org/gitlab/-/issues/328500. + if (filter_by_updated_at? && !order_by_updated_at?) || (!filter_by_updated_at? && order_by_updated_at?) + Gitlab::ErrorTracking.track_and_raise_for_dev_exception( + InefficientQueryError.new('`updated_at` filter and `updated_at` sorting must be paired') + ) + end + + if (filter_by_finished_at? && !order_by_finished_at?) || (!filter_by_finished_at? && order_by_finished_at?) + raise InefficientQueryError, '`finished_at` filter and `finished_at` sorting must be paired' + end + + if filter_by_finished_at? && !filter_by_successful_deployment? + raise InefficientQueryError, '`finished_at` filter must be combined with `success` status filter.' + end + + if params[:environment].present? && !params[:project].present? + raise InefficientQueryError, '`environment` filter must be combined with `project` scope.' + end + end + def init_collection - if params[:project] + if params[:project].present? params[:project].deployments + elsif params[:group].present? + ::Deployment.for_projects(params[:group].all_projects) else - Deployment.none + ::Deployment.none end end def sort(items) + sort_params = build_sort_params + optimize_sort_params!(sort_params) items.order(sort_params) # rubocop: disable CodeReuse/ActiveRecord end @@ -65,8 +106,8 @@ class DeploymentsFinder end def by_environment(items) - if params[:environment].present? - items.for_environment_name(params[:environment]) + if params[:project].present? && params[:environment].present? + items.for_environment_name(params[:project], params[:environment]) else items end @@ -82,14 +123,60 @@ class DeploymentsFinder items.for_status(params[:status]) end - def sort_params + def build_sort_params order_by = ALLOWED_SORT_VALUES.include?(params[:order_by]) ? params[:order_by] : DEFAULT_SORT_VALUE order_direction = ALLOWED_SORT_DIRECTIONS.include?(params[:sort]) ? params[:sort] : DEFAULT_SORT_DIRECTION - { order_by => order_direction }.tap do |sort_values| - sort_values['id'] = 'desc' if sort_values['updated_at'] - sort_values['id'] = sort_values.delete('created_at') if sort_values['created_at'] # Sorting by `id` produces the same result as sorting by `created_at` + { order_by => order_direction } + end + + def optimize_sort_params!(sort_params) + sort_direction = sort_params.each_value.first + + # Implicitly enforce the ordering when filtered by `updated_at` column for performance optimization. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/325627#note_552417509. + # We remove this in https://gitlab.com/gitlab-org/gitlab/-/issues/328500. + if filter_by_updated_at? && implicitly_enforce_ordering_for_updated_at_filter? + sort_params.replace('updated_at' => sort_direction) end + + if sort_params['created_at'] || sort_params['iid'] + # Sorting by `id` produces the same result as sorting by `created_at` or `iid` + sort_params.replace(id: sort_direction) + elsif sort_params['updated_at'] + # This adds the order as a tie-breaker when multiple rows have the same updated_at value. + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/20848. + sort_params.merge!(id: sort_direction) + end + end + + def filter_by_updated_at? + params[:updated_before].present? || params[:updated_after].present? + end + + def filter_by_finished_at? + params[:finished_before].present? || params[:finished_after].present? + end + + def filter_by_successful_deployment? + params[:status].to_s == 'success' + end + + def order_by_updated_at? + params[:order_by].to_s == 'updated_at' + end + + def order_by_finished_at? + params[:order_by].to_s == 'finished_at' + end + + def implicitly_enforce_ordering_for_updated_at_filter? + return false unless params[:project].present? + + ::Feature.enabled?( + :deployments_finder_implicitly_enforce_ordering_for_updated_at_filter, + params[:project], + default_enabled: :yaml) end # rubocop: disable CodeReuse/ActiveRecord @@ -113,5 +200,3 @@ class DeploymentsFinder end # rubocop: enable CodeReuse/ActiveRecord end - -DeploymentsFinder.prepend_if_ee('EE::DeploymentsFinder') diff --git a/app/finders/environment_names_finder.rb b/app/finders/environment_names_finder.rb deleted file mode 100644 index e9063ef4c90..00000000000 --- a/app/finders/environment_names_finder.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -# Finder for obtaining the unique environment names of a project or group. -# -# This finder exists so that the merge requests "environments" filter can be -# populated with a unique list of environment names. If we retrieve _just_ the -# environments, duplicates may be present (e.g. multiple projects in a group -# having a "staging" environment). -# -# In addition, this finder only produces unfoldered environments. We do this -# because when searching for environments we want to exclude review app -# environments. -class EnvironmentNamesFinder - attr_reader :project_or_group, :current_user - - def initialize(project_or_group, current_user = nil) - @project_or_group = project_or_group - @current_user = current_user - end - - def execute - all_environments.unfoldered.order_by_name.pluck_unique_names - end - - def all_environments - if project_or_group.is_a?(Namespace) - namespace_environments - else - project_environments - end - end - - def namespace_environments - # We assume reporter access is needed for the :read_environment permission - # here. This expection is also present in - # IssuableFinder::Params#min_access_level, which is used for filtering out - # merge requests that don't have the right permissions. - # - # We use this approach so we don't need to load every project into memory - # just to verify if we can see their environments. Doing so would not be - # efficient, and possibly mess up pagination if certain projects are not - # meant to be visible. - projects = project_or_group - .all_projects - .public_or_visible_to_user(current_user, Gitlab::Access::REPORTER) - - Environment.for_project(projects) - end - - def project_environments - if Ability.allowed?(current_user, :read_environment, project_or_group) - project_or_group.environments - else - Environment.none - end - end -end diff --git a/app/finders/environments/environment_names_finder.rb b/app/finders/environments/environment_names_finder.rb new file mode 100644 index 00000000000..d4928f0fc84 --- /dev/null +++ b/app/finders/environments/environment_names_finder.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Environments + # Finder for obtaining the unique environment names of a project or group. + # + # This finder exists so that the merge requests "environments" filter can be + # populated with a unique list of environment names. If we retrieve _just_ the + # environments, duplicates may be present (e.g. multiple projects in a group + # having a "staging" environment). + # + # In addition, this finder only produces unfoldered environments. We do this + # because when searching for environments we want to exclude review app + # environments. + class EnvironmentNamesFinder + attr_reader :project_or_group, :current_user + + def initialize(project_or_group, current_user = nil) + @project_or_group = project_or_group + @current_user = current_user + end + + def execute + all_environments.unfoldered.order_by_name.pluck_unique_names + end + + def all_environments + if project_or_group.is_a?(Namespace) + namespace_environments + else + project_environments + end + end + + def namespace_environments + # We assume reporter access is needed for the :read_environment permission + # here. This expection is also present in + # IssuableFinder::Params#min_access_level, which is used for filtering out + # merge requests that don't have the right permissions. + # + # We use this approach so we don't need to load every project into memory + # just to verify if we can see their environments. Doing so would not be + # efficient, and possibly mess up pagination if certain projects are not + # meant to be visible. + projects = project_or_group + .all_projects + .public_or_visible_to_user(current_user, Gitlab::Access::REPORTER) + + Environment.for_project(projects) + end + + def project_environments + if Ability.allowed?(current_user, :read_environment, project_or_group) + project_or_group.environments + else + Environment.none + end + end + end +end diff --git a/app/finders/environments/environments_by_deployments_finder.rb b/app/finders/environments/environments_by_deployments_finder.rb new file mode 100644 index 00000000000..e0ecc98b1c0 --- /dev/null +++ b/app/finders/environments/environments_by_deployments_finder.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Environments + class EnvironmentsByDeploymentsFinder + attr_reader :project, :current_user, :params + + def initialize(project, current_user, params = {}) + @project = project + @current_user = current_user + @params = params + end + + # rubocop: disable CodeReuse/ActiveRecord + def execute + deployments = project.deployments + deployments = + if ref + deployments_query = params[:with_tags] ? 'ref = :ref OR tag IS TRUE' : 'ref = :ref' + deployments.where(deployments_query, ref: ref.to_s) + elsif commit + deployments.where(sha: commit.sha) + else + deployments.none + end + + environment_ids = deployments + .group(:environment_id) + .select(:environment_id) + + environments = project.environments.available + .where(id: environment_ids) + + if params[:find_latest] + find_one(environments.order_by_last_deployed_at_desc) + else + find_all(environments.order_by_last_deployed_at.to_a) + end + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def find_one(environments) + [environments.find { |environment| valid_environment?(environment) }].compact + end + + def find_all(environments) + environments.select { |environment| valid_environment?(environment) } + end + + def valid_environment?(environment) + # Go in order of cost: SQL calls are cheaper than Gitaly calls + return false unless Ability.allowed?(current_user, :read_environment, environment) + + return false if ref && params[:recently_updated] && !environment.recently_updated_on_branch?(ref) + return false if ref && commit && !environment.includes_commit?(commit) + + true + end + + def ref + params[:ref].try(:to_s) + end + + def commit + params[:commit] + end + end +end diff --git a/app/finders/environments/environments_finder.rb b/app/finders/environments/environments_finder.rb new file mode 100644 index 00000000000..190cdb3dec3 --- /dev/null +++ b/app/finders/environments/environments_finder.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Environments + class EnvironmentsFinder + attr_reader :project, :current_user, :params + + InvalidStatesError = Class.new(StandardError) + + def initialize(project, current_user, params = {}) + @project = project + @current_user = current_user + @params = params + end + + def execute + environments = project.environments + environments = by_name(environments) + environments = by_search(environments) + + # Raises InvalidStatesError if params[:states] contains invalid states. + by_states(environments) + end + + private + + def by_name(environments) + if params[:name].present? + environments.for_name(params[:name]) + else + environments + end + end + + def by_search(environments) + if params[:search].present? + environments.for_name_like(params[:search], limit: nil) + else + environments + end + end + + def by_states(environments) + if params[:states].present? + environments_with_states(environments) + else + environments + end + end + + def environments_with_states(environments) + # Convert to array of strings + states = Array(params[:states]).map(&:to_s) + + raise InvalidStatesError, _('Requested states are invalid') unless valid_states?(states) + + environments.with_states(states) + end + + def valid_states?(states) + valid_states = Environment.valid_states.map(&:to_s) + + (states - valid_states).empty? + end + end +end diff --git a/app/finders/environments_by_deployments_finder.rb b/app/finders/environments_by_deployments_finder.rb deleted file mode 100644 index 76e1c050ea5..00000000000 --- a/app/finders/environments_by_deployments_finder.rb +++ /dev/null @@ -1,67 +0,0 @@ -# frozen_string_literal: true - -class EnvironmentsByDeploymentsFinder - attr_reader :project, :current_user, :params - - def initialize(project, current_user, params = {}) - @project = project - @current_user = current_user - @params = params - end - - # rubocop: disable CodeReuse/ActiveRecord - def execute - deployments = project.deployments - deployments = - if ref - deployments_query = params[:with_tags] ? 'ref = :ref OR tag IS TRUE' : 'ref = :ref' - deployments.where(deployments_query, ref: ref.to_s) - elsif commit - deployments.where(sha: commit.sha) - else - deployments.none - end - - environment_ids = deployments - .group(:environment_id) - .select(:environment_id) - - environments = project.environments.available - .where(id: environment_ids) - - if params[:find_latest] - find_one(environments.order_by_last_deployed_at_desc) - else - find_all(environments.order_by_last_deployed_at.to_a) - end - end - # rubocop: enable CodeReuse/ActiveRecord - - private - - def find_one(environments) - [environments.find { |environment| valid_environment?(environment) }].compact - end - - def find_all(environments) - environments.select { |environment| valid_environment?(environment) } - end - - def valid_environment?(environment) - # Go in order of cost: SQL calls are cheaper than Gitaly calls - return false unless Ability.allowed?(current_user, :read_environment, environment) - - return false if ref && params[:recently_updated] && !environment.recently_updated_on_branch?(ref) - return false if ref && commit && !environment.includes_commit?(commit) - - true - end - - def ref - params[:ref].try(:to_s) - end - - def commit - params[:commit] - end -end diff --git a/app/finders/environments_finder.rb b/app/finders/environments_finder.rb deleted file mode 100644 index c64e850f440..00000000000 --- a/app/finders/environments_finder.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -class EnvironmentsFinder - attr_reader :project, :current_user, :params - - InvalidStatesError = Class.new(StandardError) - - def initialize(project, current_user, params = {}) - @project = project - @current_user = current_user - @params = params - end - - def execute - environments = project.environments - environments = by_name(environments) - environments = by_search(environments) - - # Raises InvalidStatesError if params[:states] contains invalid states. - by_states(environments) - end - - private - - def by_name(environments) - if params[:name].present? - environments.for_name(params[:name]) - else - environments - end - end - - def by_search(environments) - if params[:search].present? - environments.for_name_like(params[:search], limit: nil) - else - environments - end - end - - def by_states(environments) - if params[:states].present? - environments_with_states(environments) - else - environments - end - end - - def environments_with_states(environments) - # Convert to array of strings - states = Array(params[:states]).map(&:to_s) - - raise InvalidStatesError, _('Requested states are invalid') unless valid_states?(states) - - environments.with_states(states) - end - - def valid_states?(states) - valid_states = Environment.valid_states.map(&:to_s) - - (states - valid_states).empty? - end -end diff --git a/app/finders/fork_targets_finder.rb b/app/finders/fork_targets_finder.rb index 719c244a207..3a79b216853 100644 --- a/app/finders/fork_targets_finder.rb +++ b/app/finders/fork_targets_finder.rb @@ -19,4 +19,4 @@ class ForkTargetsFinder attr_reader :project, :user end -ForkTargetsFinder.prepend_if_ee('EE::ForkTargetsFinder') +ForkTargetsFinder.prepend_mod_with('ForkTargetsFinder') diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index a6ecd835527..982234f7506 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -4,6 +4,12 @@ class GroupMembersFinder < UnionFinder RELATIONS = %i(direct inherited descendants).freeze DEFAULT_RELATIONS = %i(direct inherited).freeze + RELATIONS_DESCRIPTIONS = { + direct: 'Members in the group itself', + inherited: "Members in the group's ancestor groups", + descendants: "Members in the group's subgroups" + }.freeze + include CreatedAtFilter # Params can be any of the following: @@ -82,4 +88,4 @@ class GroupMembersFinder < UnionFinder end end -GroupMembersFinder.prepend_if_ee('EE::GroupMembersFinder') +GroupMembersFinder.prepend_mod_with('GroupMembersFinder') diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb index dfdf821e3f0..d3c26fd845c 100644 --- a/app/finders/group_projects_finder.rb +++ b/app/finders/group_projects_finder.rb @@ -126,4 +126,4 @@ class GroupProjectsFinder < ProjectsFinder end end -GroupProjectsFinder.prepend_if_ee('EE::GroupProjectsFinder') +GroupProjectsFinder.prepend_mod_with('GroupProjectsFinder') diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 40a4e2b4f26..d1885b5ae08 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -142,8 +142,6 @@ class IssuableFinder end def should_filter_negated_args? - return false unless not_filters_enabled? - # API endpoints send in `nil` values so we test if there are any non-nil not_params.present? && not_params.values.any? end @@ -336,8 +334,7 @@ class IssuableFinder return items if items.is_a?(ActiveRecord::NullRelation) if use_cte_for_search? - cte = Gitlab::SQL::RecursiveCTE.new(klass.table_name) - cte << items + cte = Gitlab::SQL::CTE.new(klass.table_name, items) items = klass.with(cte.to_arel).from(klass.table_name) end @@ -370,8 +367,7 @@ class IssuableFinder Issuables::AuthorFilter.new( items, params: original_params, - or_filters_enabled: or_filters_enabled?, - not_filters_enabled: not_filters_enabled? + or_filters_enabled: or_filters_enabled? ).filter end @@ -496,12 +492,6 @@ class IssuableFinder end end - def not_filters_enabled? - strong_memoize(:not_filters_enabled) do - Feature.enabled?(:not_issuable_queries, feature_flag_scope, default_enabled: :yaml) - end - end - def feature_flag_scope params.group || params.project end diff --git a/app/finders/issuables/author_filter.rb b/app/finders/issuables/author_filter.rb index ce68dbafb95..522751a384e 100644 --- a/app/finders/issuables/author_filter.rb +++ b/app/finders/issuables/author_filter.rb @@ -27,7 +27,7 @@ module Issuables end def by_negated_author(issuables) - return issuables unless not_filters_enabled? && not_params.present? + return issuables unless not_params.present? if not_params[:author_id].present? issuables.not_authored(not_params[:author_id]) diff --git a/app/finders/issuables/base_filter.rb b/app/finders/issuables/base_filter.rb index 641ae2568cc..6d1a3f96062 100644 --- a/app/finders/issuables/base_filter.rb +++ b/app/finders/issuables/base_filter.rb @@ -4,11 +4,10 @@ module Issuables class BaseFilter attr_reader :issuables, :params - def initialize(issuables, params:, or_filters_enabled: false, not_filters_enabled: false) + def initialize(issuables, params:, or_filters_enabled: false) @issuables = issuables @params = params @or_filters_enabled = or_filters_enabled - @not_filters_enabled = not_filters_enabled end def filter @@ -28,9 +27,5 @@ module Issuables def or_filters_enabled? @or_filters_enabled end - - def not_filters_enabled? - @not_filters_enabled - end end end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index e1a334413f8..eb9099fe256 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -25,7 +25,7 @@ # updated_after: datetime # updated_before: datetime # confidential: boolean -# issue_type: array of strings (one of Issue.issue_types) +# issue_types: array of strings (one of Issue.issue_types) # class IssuesFinder < IssuableFinder CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER @@ -117,4 +117,4 @@ class IssuesFinder < IssuableFinder end end -IssuesFinder.prepend_if_ee('EE::IssuesFinder') +IssuesFinder.prepend_mod_with('IssuesFinder') diff --git a/app/finders/issues_finder/params.rb b/app/finders/issues_finder/params.rb index 668d969f7c0..1de117216f8 100644 --- a/app/finders/issues_finder/params.rb +++ b/app/finders/issues_finder/params.rb @@ -45,4 +45,4 @@ class IssuesFinder end end -IssuesFinder::Params.prepend_if_ee('EE::IssuesFinder::Params') +IssuesFinder::Params.prepend_mod_with('IssuesFinder::Params') diff --git a/app/finders/license_template_finder.rb b/app/finders/license_template_finder.rb index c4cb33235af..b4235a77867 100644 --- a/app/finders/license_template_finder.rb +++ b/app/finders/license_template_finder.rb @@ -56,4 +56,4 @@ class LicenseTemplateFinder end end -LicenseTemplateFinder.prepend_if_ee('::EE::LicenseTemplateFinder') +LicenseTemplateFinder.prepend_mod_with('LicenseTemplateFinder') diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 9f9e2afa7fe..19fcd91a5b8 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -220,4 +220,4 @@ class MergeRequestsFinder < IssuableFinder end end -MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder') +MergeRequestsFinder.prepend_mod_with('MergeRequestsFinder') diff --git a/app/finders/namespaces/projects_finder.rb b/app/finders/namespaces/projects_finder.rb index bac5328d077..589a9696ea6 100644 --- a/app/finders/namespaces/projects_finder.rb +++ b/app/finders/namespaces/projects_finder.rb @@ -60,4 +60,4 @@ module Namespaces end end -Namespaces::ProjectsFinder.prepend_if_ee('::EE::Namespaces::ProjectsFinder') +Namespaces::ProjectsFinder.prepend_mod_with('Namespaces::ProjectsFinder') diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 96966001e85..42bd7a24888 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -183,4 +183,4 @@ class NotesFinder end end -NotesFinder.prepend_if_ee('EE::NotesFinder') +NotesFinder.prepend_mod_with('NotesFinder') diff --git a/app/finders/packages/composer/packages_finder.rb b/app/finders/packages/composer/packages_finder.rb index e63b2ee03fa..b5a1b19216f 100644 --- a/app/finders/packages/composer/packages_finder.rb +++ b/app/finders/packages/composer/packages_finder.rb @@ -9,7 +9,7 @@ module Packages end def execute - packages_for_group_projects.composer.preload_composer + packages_for_group_projects(installable_only: true).composer.preload_composer end end end diff --git a/app/finders/packages/conan/package_finder.rb b/app/finders/packages/conan/package_finder.rb index 26e9182f4e1..8ebdd358ba6 100644 --- a/app/finders/packages/conan/package_finder.rb +++ b/app/finders/packages/conan/package_finder.rb @@ -11,7 +11,7 @@ module Packages end def execute - packages_for_current_user.with_name_like(query).order_name_asc if query + packages_for_current_user.installable.with_name_like(query).order_name_asc if query end private diff --git a/app/finders/packages/generic/package_finder.rb b/app/finders/packages/generic/package_finder.rb index 3a260e11fa3..8ec88754901 100644 --- a/app/finders/packages/generic/package_finder.rb +++ b/app/finders/packages/generic/package_finder.rb @@ -11,6 +11,7 @@ module Packages project .packages .generic + .installable .by_name_and_version!(package_name, package_version) end diff --git a/app/finders/packages/go/package_finder.rb b/app/finders/packages/go/package_finder.rb index 4573417d11f..553e731895d 100644 --- a/app/finders/packages/go/package_finder.rb +++ b/app/finders/packages/go/package_finder.rb @@ -21,6 +21,7 @@ module Packages @project .packages .golang + .installable .with_name(@module_name) .with_version(@module_version) end diff --git a/app/finders/packages/go/version_finder.rb b/app/finders/packages/go/version_finder.rb index 6ee02b8c6f6..8500a441fb7 100644 --- a/app/finders/packages/go/version_finder.rb +++ b/app/finders/packages/go/version_finder.rb @@ -37,7 +37,7 @@ module Packages @mod.version_by(commit: target) else - raise ArgumentError.new 'not a valid target' + raise ArgumentError, 'not a valid target' end end end diff --git a/app/finders/packages/group_or_project_package_finder.rb b/app/finders/packages/group_or_project_package_finder.rb new file mode 100644 index 00000000000..fb8bcfc7d42 --- /dev/null +++ b/app/finders/packages/group_or_project_package_finder.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Packages + class GroupOrProjectPackageFinder + include ::Packages::FinderHelper + + def initialize(current_user, project_or_group, params = {}) + @current_user = current_user + @project_or_group = project_or_group + @params = params + end + + def execute + raise NotImplementedError + end + + def execute! + raise NotImplementedError + end + + private + + def packages + raise NotImplementedError + end + + def base + if project? + packages_for_project(@project_or_group) + elsif group? + packages_visible_to_user(@current_user, within_group: @project_or_group) + else + ::Packages::Package.none + end + end + + def project? + @project_or_group.is_a?(::Project) + end + + def group? + @project_or_group.is_a?(::Group) + end + end +end diff --git a/app/finders/packages/group_packages_finder.rb b/app/finders/packages/group_packages_finder.rb index 8771bf90e75..e753fa4d455 100644 --- a/app/finders/packages/group_packages_finder.rb +++ b/app/finders/packages/group_packages_finder.rb @@ -20,19 +20,19 @@ module Packages attr_reader :current_user, :group, :params - def packages_for_group_projects + def packages_for_group_projects(installable_only: false) packages = ::Packages::Package .including_build_info .including_project_route .including_tags .for_projects(group_projects_visible_to_current_user.select(:id)) - .processed .sort_by_attribute("#{params[:order_by]}_#{params[:sort]}") packages = filter_with_version(packages) packages = filter_by_package_type(packages) packages = filter_by_package_name(packages) - filter_by_status(packages) + packages = filter_by_package_version(packages) + installable_only ? packages.installable : filter_by_status(packages) end def group_projects_visible_to_current_user diff --git a/app/finders/packages/maven/package_finder.rb b/app/finders/packages/maven/package_finder.rb index eefcdaba3c8..cc28d951f52 100644 --- a/app/finders/packages/maven/package_finder.rb +++ b/app/finders/packages/maven/package_finder.rb @@ -2,83 +2,23 @@ module Packages module Maven - class PackageFinder - include ::Packages::FinderHelper - include Gitlab::Utils::StrongMemoize - - def initialize(path, current_user, project: nil, group: nil, order_by_package_file: false) - @path = path - @current_user = current_user - @project = project - @group = group - @order_by_package_file = order_by_package_file - end - + class PackageFinder < ::Packages::GroupOrProjectPackageFinder def execute - packages_with_path.last + packages.last end def execute! - packages_with_path.last! + packages.last! end private - def base - if @project - packages_for_a_single_project - elsif @group - packages_for_multiple_projects - else - ::Packages::Package.none - end - end - - def packages_with_path - matching_packages = base.only_maven_packages_with_path(@path, use_cte: @group.present?) - - if group_level_improvements? - matching_packages = matching_packages.order_by_package_file if @order_by_package_file - else - matching_packages = matching_packages.order_by_package_file if versionless_package?(matching_packages) - end + def packages + matching_packages = base.only_maven_packages_with_path(@params[:path], use_cte: group?) + matching_packages = matching_packages.order_by_package_file if @params[:order_by_package_file] matching_packages end - - def versionless_package?(matching_packages) - return if matching_packages.empty? - - # if one matching package is versionless, they all are. - matching_packages.first&.version.nil? - end - - # Produces a query that retrieves packages from a single project. - def packages_for_a_single_project - @project.packages - end - - # Produces a query that retrieves packages from multiple projects that - # the current user can view within a group. - def packages_for_multiple_projects - if group_level_improvements? - packages_visible_to_user(@current_user, within_group: @group) - else - ::Packages::Package.for_projects(projects_visible_to_current_user) - end - end - - # Returns the projects that the current user can view within a group. - def projects_visible_to_current_user - @group.all_projects - .public_or_visible_to_user(@current_user) - end - - def group_level_improvements? - strong_memoize(:group_level_improvements) do - Feature.enabled?(:maven_packages_group_level_improvements, default_enabled: :yaml) - end - end end end end diff --git a/app/finders/packages/npm/package_finder.rb b/app/finders/packages/npm/package_finder.rb index 3b79785d0e1..92ceac297ee 100644 --- a/app/finders/packages/npm/package_finder.rb +++ b/app/finders/packages/npm/package_finder.rb @@ -14,6 +14,7 @@ module Packages def execute base.npm .with_name(@package_name) + .installable .last_of_each_version .preload_files end diff --git a/app/finders/packages/nuget/package_finder.rb b/app/finders/packages/nuget/package_finder.rb index 2f66bd145ee..9ae52745bb2 100644 --- a/app/finders/packages/nuget/package_finder.rb +++ b/app/finders/packages/nuget/package_finder.rb @@ -2,51 +2,22 @@ module Packages module Nuget - class PackageFinder - include ::Packages::FinderHelper - + class PackageFinder < ::Packages::GroupOrProjectPackageFinder MAX_PACKAGES_COUNT = 300 - def initialize(current_user, project_or_group, package_name:, package_version: nil, limit: MAX_PACKAGES_COUNT) - @current_user = current_user - @project_or_group = project_or_group - @package_name = package_name - @package_version = package_version - @limit = limit - end - def execute - packages.limit_recent(@limit) + packages.limit_recent(@params[:limit] || MAX_PACKAGES_COUNT) end private - def base - if project? - @project_or_group.packages - elsif group? - packages_visible_to_user(@current_user, within_group: @project_or_group) - else - ::Packages::Package.none - end - end - def packages result = base.nuget .has_version - .processed - .with_name_like(@package_name) - result = result.with_version(@package_version) if @package_version.present? + .with_name_like(@params[:package_name]) + result = result.with_version(@params[:package_version]) if @params[:package_version].present? result end - - def project? - @project_or_group.is_a?(::Project) - end - - def group? - @project_or_group.is_a?(::Group) - end end end end diff --git a/app/finders/packages/package_finder.rb b/app/finders/packages/package_finder.rb index f1874b77845..ee96896e350 100644 --- a/app/finders/packages/package_finder.rb +++ b/app/finders/packages/package_finder.rb @@ -12,7 +12,7 @@ module Packages .including_build_info .including_project_route .including_tags - .processed + .displayable .find(@package_id) end end diff --git a/app/finders/packages/packages_finder.rb b/app/finders/packages/packages_finder.rb index 840cbbf7b9d..552468ecfd1 100644 --- a/app/finders/packages/packages_finder.rb +++ b/app/finders/packages/packages_finder.rb @@ -17,7 +17,6 @@ module Packages .including_build_info .including_project_route .including_tags - .processed packages = filter_with_version(packages) packages = filter_by_package_type(packages) packages = filter_by_package_name(packages) diff --git a/app/finders/packages/pypi/package_finder.rb b/app/finders/packages/pypi/package_finder.rb new file mode 100644 index 00000000000..574e9770363 --- /dev/null +++ b/app/finders/packages/pypi/package_finder.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Packages + module Pypi + class PackageFinder < ::Packages::GroupOrProjectPackageFinder + def execute + packages.by_file_name_and_sha256(@params[:filename], @params[:sha256]) + end + + private + + def packages + base.pypi.has_version + end + end + end +end diff --git a/app/finders/packages/pypi/packages_finder.rb b/app/finders/packages/pypi/packages_finder.rb new file mode 100644 index 00000000000..642ca2cf2e6 --- /dev/null +++ b/app/finders/packages/pypi/packages_finder.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Packages + module Pypi + class PackagesFinder < ::Packages::GroupOrProjectPackageFinder + def execute! + results = packages.with_normalized_pypi_name(@params[:package_name]) + raise ActiveRecord::RecordNotFound if results.empty? + + results + end + + private + + def packages + base.pypi.has_version + end + end + end +end diff --git a/app/finders/projects/groups_finder.rb b/app/finders/projects/groups_finder.rb index d0c42ad5611..fb98edabf58 100644 --- a/app/finders/projects/groups_finder.rb +++ b/app/finders/projects/groups_finder.rb @@ -7,6 +7,7 @@ # current_user - which user is requesting groups # params: # with_shared: boolean (optional) +# shared_visible_only: boolean (optional) # shared_min_access_level: integer (optional) # skip_groups: array of integers (optional) # @@ -37,25 +38,35 @@ module Projects Ability.allowed?(current_user, :read_project, project) end - # rubocop: disable CodeReuse/ActiveRecord def all_groups groups = [] - groups << project.group.self_and_ancestors if project.group + groups += [project.group.self_and_ancestors] if project.group + groups += with_shared_groups if params[:with_shared] + + return [Group.none] if groups.compact.empty? - if params[:with_shared] - shared_groups = project.invited_groups + groups + end - if params[:shared_min_access_level] - shared_groups = shared_groups.where( - 'project_group_links.group_access >= ?', params[:shared_min_access_level] - ) - end + def with_shared_groups + shared_groups = project.invited_groups + shared_groups = apply_min_access_level(shared_groups) - groups << shared_groups + if params[:shared_visible_only] + [ + shared_groups.public_to_user(current_user), + shared_groups.for_authorized_group_members(current_user&.id) + ] + else + [shared_groups] end + end - groups << Group.none if groups.compact.empty? - groups + # rubocop: disable CodeReuse/ActiveRecord + def apply_min_access_level(groups) + return groups unless params[:shared_min_access_level] + + groups.where('project_group_links.group_access >= ?', params[:shared_min_access_level]) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/projects/members/effective_access_level_finder.rb b/app/finders/projects/members/effective_access_level_finder.rb new file mode 100644 index 00000000000..2880d6667ce --- /dev/null +++ b/app/finders/projects/members/effective_access_level_finder.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Projects + module Members + class EffectiveAccessLevelFinder + include Gitlab::Utils::StrongMemoize + + USER_ID_AND_ACCESS_LEVEL = [:user_id, :access_level].freeze + BATCH_SIZE = 5 + + def initialize(project) + @project = project + end + + def execute + return Member.none if no_members? + + # rubocop: disable CodeReuse/ActiveRecord + Member.from(generate_from_statement(user_ids_and_access_levels_from_all_memberships)) + .select([:user_id, 'MAX(access_level) AS access_level']) + .group(:user_id) + # rubocop: enable CodeReuse/ActiveRecord + end + + private + + attr_reader :project + + def generate_from_statement(user_ids_and_access_levels) + "(VALUES #{generate_values_expression(user_ids_and_access_levels)}) members (user_id, access_level)" + end + + def generate_values_expression(user_ids_and_access_levels) + user_ids_and_access_levels.map do |user_id, access_level| + "(#{user_id}, #{access_level})" + end.join(",") + end + + def no_members? + user_ids_and_access_levels_from_all_memberships.blank? + end + + def all_possible_avenues_of_membership + avenues = [authorizable_project_members] + + avenues << if project.personal? + project_owner_acting_as_maintainer + else + authorizable_group_members + end + + if include_membership_from_project_group_shares? + avenues << members_from_project_group_shares + end + + avenues + end + + # @return [Array<[user_id, access_level]>] + def user_ids_and_access_levels_from_all_memberships + strong_memoize(:user_ids_and_access_levels_from_all_memberships) do + all_possible_avenues_of_membership.flat_map do |relation| + relation.pluck(*USER_ID_AND_ACCESS_LEVEL) # rubocop: disable CodeReuse/ActiveRecord + end + end + end + + def authorizable_project_members + project.members.authorizable + end + + def authorizable_group_members + project.group.authorizable_members_with_parents + end + + def members_from_project_group_shares + members = [] + + project.project_group_links.each_batch(of: BATCH_SIZE) do |relation| + members_per_batch = [] + + relation.includes(:group).each do |link| # rubocop: disable CodeReuse/ActiveRecord + members_per_batch << link.group.authorizable_members_with_parents.select(*user_id_and_access_level_for_project_group_shares(link)) + end + + members << Member.from_union(members_per_batch) + end + + members.flatten + end + + def project_owner_acting_as_maintainer + user_id = project.namespace.owner.id + access_level = Gitlab::Access::MAINTAINER + + Member + .from(generate_from_statement([[user_id, access_level]])) # rubocop: disable CodeReuse/ActiveRecord + .limit(1) + end + + def include_membership_from_project_group_shares? + project.allowed_to_share_with_group? && project.project_group_links.any? + end + + # methods for `select` options + + def user_id_and_access_level_for_project_group_shares(link) + least_access_level_among_group_membership_and_project_share = + smallest_value_arel([link.group_access, GroupMember.arel_table[:access_level]], 'access_level') + + [ + :user_id, + least_access_level_among_group_membership_and_project_share + ] + end + + def smallest_value_arel(args, column_alias) + Arel::Nodes::As.new( + Arel::Nodes::NamedFunction.new('LEAST', args), + Arel.sql(column_alias) + ) + end + end + end +end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 893e89daa3c..272747a124e 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -247,4 +247,4 @@ class ProjectsFinder < UnionFinder end end -ProjectsFinder.prepend_if_ee('::EE::ProjectsFinder') +ProjectsFinder.prepend_mod_with('ProjectsFinder') diff --git a/app/finders/repositories/branch_names_finder.rb b/app/finders/repositories/branch_names_finder.rb index 5bb67425aa5..8c8c7405407 100644 --- a/app/finders/repositories/branch_names_finder.rb +++ b/app/finders/repositories/branch_names_finder.rb @@ -10,9 +10,9 @@ module Repositories end def execute - return unless search + return unless search && offset && limit - repository.search_branch_names(search) + repository.search_branch_names(search).lazy.drop(offset).take(limit) # rubocop:disable CodeReuse/ActiveRecord end private @@ -20,5 +20,13 @@ module Repositories def search @params[:search].presence end + + def offset + @params[:offset] + end + + def limit + @params[:limit] + end end end diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index 941abb70400..81643826782 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -221,4 +221,4 @@ class SnippetsFinder < UnionFinder end end -SnippetsFinder.prepend_if_ee('EE::SnippetsFinder') +SnippetsFinder.prepend_mod_with('SnippetsFinder') diff --git a/app/finders/template_finder.rb b/app/finders/template_finder.rb index 739beee236c..0f5622f2df0 100644 --- a/app/finders/template_finder.rb +++ b/app/finders/template_finder.rb @@ -7,7 +7,6 @@ class TemplateFinder dockerfiles: ::Gitlab::Template::DockerfileTemplate, gitignores: ::Gitlab::Template::GitignoreTemplate, gitlab_ci_ymls: ::Gitlab::Template::GitlabCiYmlTemplate, - gitlab_ci_syntax_ymls: ::Gitlab::Template::GitlabCiSyntaxYmlTemplate, metrics_dashboard_ymls: ::Gitlab::Template::MetricsDashboardTemplate, issues: ::Gitlab::Template::IssueTemplate, merge_requests: ::Gitlab::Template::MergeRequestTemplate @@ -71,4 +70,4 @@ class TemplateFinder end end -TemplateFinder.prepend_if_ee('::EE::TemplateFinder') +TemplateFinder.prepend_mod_with('TemplateFinder') diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index f28e1281488..e83018ed24c 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -215,4 +215,4 @@ class TodosFinder end end -TodosFinder.prepend_if_ee('EE::TodosFinder') +TodosFinder.prepend_mod_with('TodosFinder') diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb index 5ac905e0dd4..8054ecbd502 100644 --- a/app/finders/users_finder.rb +++ b/app/finders/users_finder.rb @@ -143,4 +143,4 @@ class UsersFinder # rubocop: enable CodeReuse/ActiveRecord end -UsersFinder.prepend_if_ee('EE::UsersFinder') +UsersFinder.prepend_mod_with('UsersFinder') diff --git a/app/finders/users_with_pending_todos_finder.rb b/app/finders/users_with_pending_todos_finder.rb deleted file mode 100644 index 461bd92a366..00000000000 --- a/app/finders/users_with_pending_todos_finder.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# Finder that given a target (e.g. an issue) finds all the users that have -# pending todos for said target. -class UsersWithPendingTodosFinder - attr_reader :target - - # target - The target, such as an Issue or MergeRequest. - def initialize(target) - @target = target - end - - def execute - User.for_todos(target.todos.pending) - end -end diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 8369d0e120f..84941fcde02 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -10,6 +10,7 @@ class GitlabSchema < GraphQL::Schema DEFAULT_MAX_DEPTH = 15 AUTHENTICATED_MAX_DEPTH = 20 + use GraphQL::Subscriptions::ActionCableSubscriptions use GraphQL::Pagination::Connections use BatchLoader::GraphQL use Gitlab::Graphql::Pagination::Connections @@ -24,6 +25,7 @@ class GitlabSchema < GraphQL::Schema query Types::QueryType mutation Types::MutationType + subscription Types::SubscriptionType default_max_page_size 100 @@ -168,7 +170,7 @@ class GitlabSchema < GraphQL::Schema end end -GitlabSchema.prepend_if_ee('EE::GitlabSchema') # rubocop: disable Cop/InjectEnterpriseEditionModule +GitlabSchema.prepend_mod_with('GitlabSchema') # rubocop: disable Cop/InjectEnterpriseEditionModule # Force the schema to load as a workaround for intermittent errors we # see due to a lack of thread safety. diff --git a/app/graphql/graphql_triggers.rb b/app/graphql/graphql_triggers.rb new file mode 100644 index 00000000000..671c7c2cd25 --- /dev/null +++ b/app/graphql/graphql_triggers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module GraphqlTriggers + def self.issuable_assignees_updated(issuable) + GitlabSchema.subscriptions.trigger('issuableAssigneesUpdated', { issuable_id: issuable.to_gid }, issuable) + end +end diff --git a/app/graphql/mutations/alert_management/http_integration/create.rb b/app/graphql/mutations/alert_management/http_integration/create.rb index 2d7bffb4333..54803855bcf 100644 --- a/app/graphql/mutations/alert_management/http_integration/create.rb +++ b/app/graphql/mutations/alert_management/http_integration/create.rb @@ -34,4 +34,4 @@ module Mutations end end -Mutations::AlertManagement::HttpIntegration::Create.prepend_if_ee('::EE::Mutations::AlertManagement::HttpIntegration::Create') +Mutations::AlertManagement::HttpIntegration::Create.prepend_mod_with('Mutations::AlertManagement::HttpIntegration::Create') diff --git a/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb index e33b7bb399a..efa92bfe895 100644 --- a/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb +++ b/app/graphql/mutations/alert_management/http_integration/http_integration_base.rb @@ -33,4 +33,4 @@ module Mutations end end -Mutations::AlertManagement::HttpIntegration::HttpIntegrationBase.prepend_if_ee('::EE::Mutations::AlertManagement::HttpIntegration::HttpIntegrationBase') +Mutations::AlertManagement::HttpIntegration::HttpIntegrationBase.prepend_mod_with('Mutations::AlertManagement::HttpIntegration::HttpIntegrationBase') diff --git a/app/graphql/mutations/alert_management/http_integration/update.rb b/app/graphql/mutations/alert_management/http_integration/update.rb index b1e4ce841ee..4e6e7995c10 100644 --- a/app/graphql/mutations/alert_management/http_integration/update.rb +++ b/app/graphql/mutations/alert_management/http_integration/update.rb @@ -32,4 +32,4 @@ module Mutations end end -Mutations::AlertManagement::HttpIntegration::Update.prepend_if_ee('::EE::Mutations::AlertManagement::HttpIntegration::Update') +Mutations::AlertManagement::HttpIntegration::Update.prepend_mod_with('Mutations::AlertManagement::HttpIntegration::Update') diff --git a/app/graphql/mutations/boards/create.rb b/app/graphql/mutations/boards/create.rb index 003c4f7761b..44fc22cf883 100644 --- a/app/graphql/mutations/boards/create.rb +++ b/app/graphql/mutations/boards/create.rb @@ -30,4 +30,4 @@ module Mutations end end -Mutations::Boards::Create.prepend_if_ee('::EE::Mutations::Boards::Create') +Mutations::Boards::Create.prepend_mod_with('Mutations::Boards::Create') diff --git a/app/graphql/mutations/boards/issues/issue_move_list.rb b/app/graphql/mutations/boards/issues/issue_move_list.rb index f32205643da..4c9752c6343 100644 --- a/app/graphql/mutations/boards/issues/issue_move_list.rb +++ b/app/graphql/mutations/boards/issues/issue_move_list.rb @@ -86,4 +86,4 @@ module Mutations end end -Mutations::Boards::Issues::IssueMoveList.prepend_if_ee('EE::Mutations::Boards::Issues::IssueMoveList') +Mutations::Boards::Issues::IssueMoveList.prepend_mod_with('Mutations::Boards::Issues::IssueMoveList') diff --git a/app/graphql/mutations/boards/lists/base_update.rb b/app/graphql/mutations/boards/lists/base_update.rb new file mode 100644 index 00000000000..b06cb3b1e32 --- /dev/null +++ b/app/graphql/mutations/boards/lists/base_update.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Mutations + module Boards + module Lists + class BaseUpdate < BaseMutation + argument :position, GraphQL::INT_TYPE, + required: false, + description: 'Position of list within the board.' + + argument :collapsed, GraphQL::BOOLEAN_TYPE, + required: false, + description: 'Indicates if the list is collapsed for this user.' + + def resolve(list: nil, **args) + if list.nil? || !can_read_list?(list) + raise_resource_not_available_error! + end + + update_result = update_list(list, args) + + { + list: update_result.payload[:list], + errors: update_result.errors + } + end + + private + + def update_list(list, args) + raise NotImplementedError + end + + def can_read_list?(list) + raise NotImplementedError + end + end + end + end +end diff --git a/app/graphql/mutations/boards/lists/create.rb b/app/graphql/mutations/boards/lists/create.rb index 673fa95fc56..590a905ab7b 100644 --- a/app/graphql/mutations/boards/lists/create.rb +++ b/app/graphql/mutations/boards/lists/create.rb @@ -34,4 +34,4 @@ module Mutations end end -Mutations::Boards::Lists::Create.prepend_if_ee('::EE::Mutations::Boards::Lists::Create') +Mutations::Boards::Lists::Create.prepend_mod_with('Mutations::Boards::Lists::Create') diff --git a/app/graphql/mutations/boards/lists/update.rb b/app/graphql/mutations/boards/lists/update.rb index 504082ec22c..d17dd5162a0 100644 --- a/app/graphql/mutations/boards/lists/update.rb +++ b/app/graphql/mutations/boards/lists/update.rb @@ -3,7 +3,7 @@ module Mutations module Boards module Lists - class Update < BaseMutation + class Update < BaseUpdate graphql_name 'UpdateBoardList' argument :list_id, Types::GlobalIDType[List], @@ -11,29 +11,11 @@ module Mutations loads: Types::BoardListType, description: 'Global ID of the list.' - argument :position, GraphQL::INT_TYPE, - required: false, - description: 'Position of list within the board.' - - argument :collapsed, GraphQL::BOOLEAN_TYPE, - required: false, - description: 'Indicates if the list is collapsed for this user.' - field :list, Types::BoardListType, null: true, description: 'Mutated list.' - def resolve(list: nil, **args) - raise_resource_not_available_error! unless can_read_list?(list) - update_result = update_list(list, args) - - { - list: update_result[:list], - errors: list.errors.full_messages - } - end - private def update_list(list, args) @@ -42,8 +24,6 @@ module Mutations end def can_read_list?(list) - return false unless list.present? - Ability.allowed?(current_user, :read_issue_board_list, list.board) end end diff --git a/app/graphql/mutations/boards/update.rb b/app/graphql/mutations/boards/update.rb index 628b3a3fadb..f1a1d57306b 100644 --- a/app/graphql/mutations/boards/update.rb +++ b/app/graphql/mutations/boards/update.rb @@ -42,4 +42,4 @@ module Mutations end end -Mutations::Boards::Update.prepend_if_ee('::EE::Mutations::Boards::Update') +Mutations::Boards::Update.prepend_mod_with('Mutations::Boards::Update') diff --git a/app/graphql/mutations/ci/ci_cd_settings_update.rb b/app/graphql/mutations/ci/ci_cd_settings_update.rb index d7451babaea..a484c2438a4 100644 --- a/app/graphql/mutations/ci/ci_cd_settings_update.rb +++ b/app/graphql/mutations/ci/ci_cd_settings_update.rb @@ -36,4 +36,4 @@ module Mutations end end -Mutations::Ci::CiCdSettingsUpdate.prepend_if_ee('::EE::Mutations::Ci::CiCdSettingsUpdate') +Mutations::Ci::CiCdSettingsUpdate.prepend_mod_with('Mutations::Ci::CiCdSettingsUpdate') diff --git a/app/graphql/mutations/ci/job/base.rb b/app/graphql/mutations/ci/job/base.rb new file mode 100644 index 00000000000..3359def159a --- /dev/null +++ b/app/graphql/mutations/ci/job/base.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Job + class Base < BaseMutation + JobID = ::Types::GlobalIDType[::Ci::Build] + + argument :id, JobID, + required: true, + description: 'The ID of the job to mutate.' + + def find_object(id: ) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = JobID.coerce_isolated_input(id) + GlobalID::Locator.locate(id) + end + end + end + end +end diff --git a/app/graphql/mutations/ci/job/play.rb b/app/graphql/mutations/ci/job/play.rb new file mode 100644 index 00000000000..f87904f8b25 --- /dev/null +++ b/app/graphql/mutations/ci/job/play.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Job + class Play < Base + graphql_name 'JobPlay' + + field :job, + Types::Ci::JobType, + null: true, + description: 'The job after the mutation.' + + authorize :update_build + + def resolve(id:) + job = authorized_find!(id: id) + project = job.project + + ::Ci::PlayBuildService.new(project, current_user).execute(job) + { + job: job, + errors: errors_on_object(job) + } + end + end + end + end +end diff --git a/app/graphql/mutations/ci/job/retry.rb b/app/graphql/mutations/ci/job/retry.rb new file mode 100644 index 00000000000..a61d5dddb40 --- /dev/null +++ b/app/graphql/mutations/ci/job/retry.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Job + class Retry < Base + graphql_name 'JobRetry' + + field :job, + Types::Ci::JobType, + null: true, + description: 'The job after the mutation.' + + authorize :update_build + + def resolve(id:) + job = authorized_find!(id: id) + project = job.project + + ::Ci::RetryBuildService.new(project, current_user).execute(job) + { + job: job, + errors: errors_on_object(job) + } + end + end + end + end +end diff --git a/app/graphql/mutations/commits/create.rb b/app/graphql/mutations/commits/create.rb index 84933fee5d2..2e06e1ea0c4 100644 --- a/app/graphql/mutations/commits/create.rb +++ b/app/graphql/mutations/commits/create.rb @@ -5,6 +5,11 @@ module Mutations class Create < BaseMutation include FindsProject + class UrlHelpers + include GitlabRoutingHelper + include Gitlab::Routing + end + graphql_name 'CommitCreate' argument :project_path, GraphQL::ID_TYPE, @@ -29,6 +34,11 @@ module Mutations required: true, description: 'Array of action hashes to commit as a batch.' + field :commit_pipeline_path, + GraphQL::STRING_TYPE, + null: true, + description: "ETag path for the commit's pipeline." + field :commit, Types::CommitType, null: true, @@ -50,6 +60,7 @@ module Mutations { commit: (project.repository.commit(result[:result]) if result[:status] == :success), + commit_pipeline_path: UrlHelpers.new.graphql_etag_pipeline_sha_path(result[:result]), errors: Array.wrap(result[:message]) } end diff --git a/app/graphql/mutations/concerns/mutations/assignable.rb b/app/graphql/mutations/concerns/mutations/assignable.rb index d3ab0a1779a..e214a57500c 100644 --- a/app/graphql/mutations/concerns/mutations/assignable.rb +++ b/app/graphql/mutations/concerns/mutations/assignable.rb @@ -33,9 +33,9 @@ module Mutations def assign!(resource, users, operation_mode) update_service_class.new( - resource.project, - current_user, - assignee_ids: assignee_ids(resource, users, operation_mode) + project: resource.project, + current_user: current_user, + params: { assignee_ids: assignee_ids(resource, users, operation_mode) } ).execute(resource) end diff --git a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb index 0fe2d09de6d..fd9031d3ea7 100644 --- a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb +++ b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb @@ -38,4 +38,4 @@ module Mutations end end -Mutations::ResolvesIssuable.prepend_if_ee('::EE::Mutations::ResolvesIssuable') +Mutations::ResolvesIssuable.prepend_mod_with('Mutations::ResolvesIssuable') diff --git a/app/graphql/mutations/issues/common_mutation_arguments.rb b/app/graphql/mutations/issues/common_mutation_arguments.rb index 4b5b246281f..65768b85d14 100644 --- a/app/graphql/mutations/issues/common_mutation_arguments.rb +++ b/app/graphql/mutations/issues/common_mutation_arguments.rb @@ -22,6 +22,11 @@ module Mutations as: :discussion_locked, required: false, description: copy_field_description(Types::IssueType, :discussion_locked) + + argument :type, Types::IssueTypeEnum, + as: :issue_type, + required: false, + description: copy_field_description(Types::IssueType, :type) end end end diff --git a/app/graphql/mutations/issues/create.rb b/app/graphql/mutations/issues/create.rb index 37fddd92832..3a57e2434a5 100644 --- a/app/graphql/mutations/issues/create.rb +++ b/app/graphql/mutations/issues/create.rb @@ -73,7 +73,7 @@ module Mutations project = authorized_find!(project_path) params = build_create_issue_params(attributes.merge(author_id: current_user.id)) - issue = ::Issues::CreateService.new(project, current_user, params).execute + issue = ::Issues::CreateService.new(project: project, current_user: current_user, params: params).execute if issue.spam? issue.errors.add(:base, 'Spam detected.') @@ -102,4 +102,4 @@ module Mutations end end -Mutations::Issues::Create.prepend_if_ee('::EE::Mutations::Issues::Create') +Mutations::Issues::Create.prepend_mod_with('Mutations::Issues::Create') diff --git a/app/graphql/mutations/issues/move.rb b/app/graphql/mutations/issues/move.rb index 0f2af99bf61..cb4f0f42b38 100644 --- a/app/graphql/mutations/issues/move.rb +++ b/app/graphql/mutations/issues/move.rb @@ -18,7 +18,7 @@ module Mutations target_project = resolve_project(full_path: target_project_path).sync begin - moved_issue = ::Issues::MoveService.new(source_project, current_user).execute(issue, target_project) + moved_issue = ::Issues::MoveService.new(project: source_project, current_user: current_user).execute(issue, target_project) rescue ::Issues::MoveService::MoveError => error errors = error.message end diff --git a/app/graphql/mutations/issues/set_confidential.rb b/app/graphql/mutations/issues/set_confidential.rb index 75befddc261..8e88b31d9ed 100644 --- a/app/graphql/mutations/issues/set_confidential.rb +++ b/app/graphql/mutations/issues/set_confidential.rb @@ -14,7 +14,7 @@ module Mutations issue = authorized_find!(project_path: project_path, iid: iid) project = issue.project - ::Issues::UpdateService.new(project, current_user, confidential: confidential) + ::Issues::UpdateService.new(project: project, current_user: current_user, params: { confidential: confidential }) .execute(issue) { diff --git a/app/graphql/mutations/issues/set_due_date.rb b/app/graphql/mutations/issues/set_due_date.rb index da7892f4ed4..9cefac96b25 100644 --- a/app/graphql/mutations/issues/set_due_date.rb +++ b/app/graphql/mutations/issues/set_due_date.rb @@ -7,14 +7,23 @@ module Mutations argument :due_date, Types::TimeType, - required: true, - description: 'The desired due date for the issue.' + required: false, + description: 'The desired due date for the issue, ' \ + 'due date will be removed if absent or set to null' + + def ready?(**args) + unless args.key?(:due_date) + raise Gitlab::Graphql::Errors::ArgumentError, 'Argument dueDate must be provided (`null` accepted)' + end + + super + end def resolve(project_path:, iid:, due_date:) issue = authorized_find!(project_path: project_path, iid: iid) project = issue.project - ::Issues::UpdateService.new(project, current_user, due_date: due_date) + ::Issues::UpdateService.new(project: project, current_user: current_user, params: { due_date: due_date }) .execute(issue) { diff --git a/app/graphql/mutations/issues/set_locked.rb b/app/graphql/mutations/issues/set_locked.rb index 611226e48ad..3a696a64dad 100644 --- a/app/graphql/mutations/issues/set_locked.rb +++ b/app/graphql/mutations/issues/set_locked.rb @@ -13,7 +13,7 @@ module Mutations def resolve(project_path:, iid:, locked:) issue = authorized_find!(project_path: project_path, iid: iid) - ::Issues::UpdateService.new(issue.project, current_user, discussion_locked: locked) + ::Issues::UpdateService.new(project: issue.project, current_user: current_user, params: { discussion_locked: locked }) .execute(issue) { diff --git a/app/graphql/mutations/issues/set_severity.rb b/app/graphql/mutations/issues/set_severity.rb index bc386e07178..778563ba053 100644 --- a/app/graphql/mutations/issues/set_severity.rb +++ b/app/graphql/mutations/issues/set_severity.rb @@ -12,7 +12,7 @@ module Mutations issue = authorized_find!(project_path: project_path, iid: iid) project = issue.project - ::Issues::UpdateService.new(project, current_user, severity: severity) + ::Issues::UpdateService.new(project: project, current_user: current_user, params: { severity: severity }) .execute(issue) { diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb index eea2cd49aa0..eb16b7b38d0 100644 --- a/app/graphql/mutations/issues/update.rb +++ b/app/graphql/mutations/issues/update.rb @@ -31,7 +31,7 @@ module Mutations issue = authorized_find!(project_path: project_path, iid: iid) project = issue.project - ::Issues::UpdateService.new(project, current_user, args).execute(issue) + ::Issues::UpdateService.new(project: project, current_user: current_user, params: args).execute(issue) { issue: issue, @@ -42,4 +42,4 @@ module Mutations end end -Mutations::Issues::Update.prepend_if_ee('::EE::Mutations::Issues::Update') +Mutations::Issues::Update.prepend_mod_with('Mutations::Issues::Update') diff --git a/app/graphql/mutations/labels/create.rb b/app/graphql/mutations/labels/create.rb index ccbd1c37cbf..4da628d53ea 100644 --- a/app/graphql/mutations/labels/create.rb +++ b/app/graphql/mutations/labels/create.rb @@ -20,10 +20,21 @@ module Mutations required: false, description: 'Description of the label.' + argument :remove_on_close, GraphQL::BOOLEAN_TYPE, + required: false, + description: copy_field_description(Types::LabelType, :remove_on_close) + argument :color, GraphQL::STRING_TYPE, required: false, default_value: Label::DEFAULT_COLOR, - description: "The color of the label given in 6-digit hex notation with leading '#' sign (e.g. #FFAABB) or one of the CSS color names in https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#Color_keywords." + see: { + 'List of color keywords at mozilla.org' => + 'https://developer.mozilla.org/en-US/docs/Web/CSS/color_value#Color_keywords' + }, + description: <<~DESC + The color of the label given in 6-digit hex notation with leading '#' sign + (for example, `#FFAABB`) or one of the CSS color names. + DESC authorize :admin_label diff --git a/app/graphql/mutations/merge_requests/accept.rb b/app/graphql/mutations/merge_requests/accept.rb index da94dcd8890..9994f793a01 100644 --- a/app/graphql/mutations/merge_requests/accept.rb +++ b/app/graphql/mutations/merge_requests/accept.rb @@ -47,7 +47,7 @@ module Mutations merge_request = authorized_find!(project_path: project_path, iid: iid) project = merge_request.target_project merge_params = args.compact.with_indifferent_access - merge_service = ::MergeRequests::MergeService.new(project, current_user, merge_params) + merge_service = ::MergeRequests::MergeService.new(project: project, current_user: current_user, params: merge_params) if error = validate(merge_request, merge_service, merge_params) return { merge_request: merge_request, errors: [error] } diff --git a/app/graphql/mutations/merge_requests/create.rb b/app/graphql/mutations/merge_requests/create.rb index 9ac8f70be95..4849c198677 100644 --- a/app/graphql/mutations/merge_requests/create.rb +++ b/app/graphql/mutations/merge_requests/create.rb @@ -42,7 +42,7 @@ module Mutations project = authorized_find!(project_path) params = attributes.merge(author_id: current_user.id) - merge_request = ::MergeRequests::CreateService.new(project, current_user, params).execute + merge_request = ::MergeRequests::CreateService.new(project: project, current_user: current_user, params: params).execute { merge_request: merge_request.valid? ? merge_request : nil, diff --git a/app/graphql/mutations/merge_requests/reviewer_rereview.rb b/app/graphql/mutations/merge_requests/reviewer_rereview.rb index f6f4881654e..d1d5118e271 100644 --- a/app/graphql/mutations/merge_requests/reviewer_rereview.rb +++ b/app/graphql/mutations/merge_requests/reviewer_rereview.rb @@ -15,7 +15,7 @@ module Mutations def resolve(project_path:, iid:, user:) merge_request = authorized_find!(project_path: project_path, iid: iid) - result = ::MergeRequests::RequestReviewService.new(merge_request.project, current_user).execute(merge_request, user) + result = ::MergeRequests::RequestReviewService.new(project: merge_request.project, current_user: current_user).execute(merge_request, user) { merge_request: merge_request, diff --git a/app/graphql/mutations/merge_requests/set_draft.rb b/app/graphql/mutations/merge_requests/set_draft.rb new file mode 100644 index 00000000000..80006c6f70e --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_draft.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetDraft < Base + graphql_name 'MergeRequestSetDraft' + + argument :draft, + GraphQL::BOOLEAN_TYPE, + required: true, + description: <<~DESC + Whether or not to set the merge request as a draft. + DESC + + def resolve(project_path:, iid:, draft: nil) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + ::MergeRequests::UpdateService.new(project: project, current_user: current_user, params: { wip_event: wip_event(draft) }) + .execute(merge_request) + + { + merge_request: merge_request, + errors: errors_on_object(merge_request) + } + end + + private + + def wip_event(wip) + wip ? 'wip' : 'unwip' + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_labels.rb b/app/graphql/mutations/merge_requests/set_labels.rb index 712c68c9425..a77c2731a05 100644 --- a/app/graphql/mutations/merge_requests/set_labels.rb +++ b/app/graphql/mutations/merge_requests/set_labels.rb @@ -9,14 +9,14 @@ module Mutations [::Types::GlobalIDType[Label]], required: true, description: <<~DESC - The Label IDs to set. Replaces existing labels by default. + The Label IDs to set. Replaces existing labels by default. DESC argument :operation_mode, Types::MutationOperationModeEnum, required: false, description: <<~DESC - Changes the operation mode. Defaults to REPLACE. + Changes the operation mode. Defaults to REPLACE. DESC def resolve(project_path:, iid:, label_ids:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) @@ -38,7 +38,7 @@ module Mutations :label_ids end - ::MergeRequests::UpdateService.new(project, current_user, attribute_name => label_ids) + ::MergeRequests::UpdateService.new(project: project, current_user: current_user, params: { attribute_name => label_ids }) .execute(merge_request) { diff --git a/app/graphql/mutations/merge_requests/set_locked.rb b/app/graphql/mutations/merge_requests/set_locked.rb index c49d5186a03..e9e607551a6 100644 --- a/app/graphql/mutations/merge_requests/set_locked.rb +++ b/app/graphql/mutations/merge_requests/set_locked.rb @@ -9,14 +9,14 @@ module Mutations GraphQL::BOOLEAN_TYPE, required: true, description: <<~DESC - Whether or not to lock the merge request. + Whether or not to lock the merge request. DESC def resolve(project_path:, iid:, locked:) merge_request = authorized_find!(project_path: project_path, iid: iid) project = merge_request.project - ::MergeRequests::UpdateService.new(project, current_user, discussion_locked: locked) + ::MergeRequests::UpdateService.new(project: project, current_user: current_user, params: { discussion_locked: locked }) .execute(merge_request) { diff --git a/app/graphql/mutations/merge_requests/set_milestone.rb b/app/graphql/mutations/merge_requests/set_milestone.rb index abcb1bda1f3..ed5139c4af9 100644 --- a/app/graphql/mutations/merge_requests/set_milestone.rb +++ b/app/graphql/mutations/merge_requests/set_milestone.rb @@ -10,14 +10,14 @@ module Mutations required: false, loads: Types::MilestoneType, description: <<~DESC - The milestone to assign to the merge request. + The milestone to assign to the merge request. DESC def resolve(project_path:, iid:, milestone: nil) merge_request = authorized_find!(project_path: project_path, iid: iid) project = merge_request.project - ::MergeRequests::UpdateService.new(project, current_user, milestone: milestone) + ::MergeRequests::UpdateService.new(project: project, current_user: current_user, params: { milestone: milestone }) .execute(merge_request) { diff --git a/app/graphql/mutations/merge_requests/set_wip.rb b/app/graphql/mutations/merge_requests/set_wip.rb index beb042ce93f..6f52b240840 100644 --- a/app/graphql/mutations/merge_requests/set_wip.rb +++ b/app/graphql/mutations/merge_requests/set_wip.rb @@ -16,7 +16,7 @@ module Mutations merge_request = authorized_find!(project_path: project_path, iid: iid) project = merge_request.project - ::MergeRequests::UpdateService.new(project, current_user, wip_event: wip_event(merge_request, wip)) + ::MergeRequests::UpdateService.new(project: project, current_user: current_user, params: { wip_event: wip_event(merge_request, wip) }) .execute(merge_request) { diff --git a/app/graphql/mutations/merge_requests/update.rb b/app/graphql/mutations/merge_requests/update.rb index 6a94d2f37b2..246e468c34c 100644 --- a/app/graphql/mutations/merge_requests/update.rb +++ b/app/graphql/mutations/merge_requests/update.rb @@ -29,7 +29,7 @@ module Mutations attributes = args.compact ::MergeRequests::UpdateService - .new(merge_request.project, current_user, attributes) + .new(project: merge_request.project, current_user: current_user, params: attributes) .execute(merge_request) errors = errors_on_object(merge_request) diff --git a/app/graphql/mutations/namespace/package_settings/update.rb b/app/graphql/mutations/namespace/package_settings/update.rb index ca21c3418fc..75c80cfbd3e 100644 --- a/app/graphql/mutations/namespace/package_settings/update.rb +++ b/app/graphql/mutations/namespace/package_settings/update.rb @@ -25,6 +25,16 @@ module Mutations required: false, description: copy_field_description(Types::Namespace::PackageSettingsType, :maven_duplicate_exception_regex) + argument :generic_duplicates_allowed, + GraphQL::BOOLEAN_TYPE, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :generic_duplicates_allowed) + + argument :generic_duplicate_exception_regex, + Types::UntrustedRegexp, + required: false, + description: copy_field_description(Types::Namespace::PackageSettingsType, :generic_duplicate_exception_regex) + field :package_settings, Types::Namespace::PackageSettingsType, null: true, diff --git a/app/graphql/mutations/security/ci_configuration/configure_sast.rb b/app/graphql/mutations/security/ci_configuration/configure_sast.rb index e4a3f815396..237aff1f052 100644 --- a/app/graphql/mutations/security/ci_configuration/configure_sast.rb +++ b/app/graphql/mutations/security/ci_configuration/configure_sast.rb @@ -7,6 +7,11 @@ module Mutations include FindsProject graphql_name 'ConfigureSast' + description <<~DESC + Configure SAST for a project by enabling SAST in a new or modified + `.gitlab-ci.yml` file in a new branch. The new branch and a URL to + create a Merge Request are a part of the response. + DESC argument :project_path, GraphQL::ID_TYPE, required: true, @@ -16,12 +21,12 @@ module Mutations required: true, description: 'SAST CI configuration for the project.' - field :status, GraphQL::STRING_TYPE, null: false, - description: 'Status of creating the commit for the supplied SAST CI configuration.' - field :success_path, GraphQL::STRING_TYPE, null: true, description: 'Redirect path to use when the response is successful.' + field :branch, GraphQL::STRING_TYPE, null: true, + description: 'Branch that has the new/modified `.gitlab-ci.yml` file.' + authorize :push_code def resolve(project_path:, configuration:) @@ -35,9 +40,9 @@ module Mutations def prepare_response(result) { - status: result[:status], - success_path: result[:success_path], - errors: Array(result[:errors]) + branch: result.payload[:branch], + success_path: result.payload[:success_path], + errors: result.errors } end end diff --git a/app/graphql/mutations/security/ci_configuration/configure_secret_detection.rb b/app/graphql/mutations/security/ci_configuration/configure_secret_detection.rb new file mode 100644 index 00000000000..32ad670edaa --- /dev/null +++ b/app/graphql/mutations/security/ci_configuration/configure_secret_detection.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Mutations + module Security + module CiConfiguration + class ConfigureSecretDetection < BaseMutation + include FindsProject + + graphql_name 'ConfigureSecretDetection' + description <<~DESC + Configure Secret Detection for a project by enabling Secret Detection + in a new or modified `.gitlab-ci.yml` file in a new branch. The new + branch and a URL to create a Merge Request are a part of the + response. + DESC + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: 'Full path of the project.' + + field :success_path, GraphQL::STRING_TYPE, null: true, + description: 'Redirect path to use when the response is successful.' + + field :branch, GraphQL::STRING_TYPE, null: true, + description: 'Branch that has the new/modified `.gitlab-ci.yml` file.' + + authorize :push_code + + def resolve(project_path:) + project = authorized_find!(project_path) + + result = ::Security::CiConfiguration::SecretDetectionCreateService.new(project, current_user).execute + prepare_response(result) + end + + private + + def prepare_response(result) + { + branch: result.payload[:branch], + success_path: result.payload[:success_path], + errors: result.errors + } + end + end + end + end +end diff --git a/app/graphql/queries/burndown_chart/burnup.query.graphql b/app/graphql/queries/burndown_chart/burnup.query.graphql new file mode 100644 index 00000000000..7a389a6def5 --- /dev/null +++ b/app/graphql/queries/burndown_chart/burnup.query.graphql @@ -0,0 +1,70 @@ +query BurnupTimesSeriesData($id: ID!, $isIteration: Boolean = false, $weight: Boolean = false) { + milestone(id: $id) @skip(if: $isIteration) { + __typename + id + title + report { + __typename + burnupTimeSeries { + __typename + date + completedCount @skip(if: $weight) + scopeCount @skip(if: $weight) + completedWeight @include(if: $weight) + scopeWeight @include(if: $weight) + } + stats { + __typename + total { + __typename + count @skip(if: $weight) + weight @include(if: $weight) + } + complete { + __typename + count @skip(if: $weight) + weight @include(if: $weight) + } + incomplete { + __typename + count @skip(if: $weight) + weight @include(if: $weight) + } + } + } + } + iteration(id: $id) @include(if: $isIteration) { + __typename + id + title + report { + __typename + burnupTimeSeries { + __typename + date + completedCount @skip(if: $weight) + scopeCount @skip(if: $weight) + completedWeight @include(if: $weight) + scopeWeight @include(if: $weight) + } + stats { + __typename + total { + __typename + count @skip(if: $weight) + weight @include(if: $weight) + } + complete { + __typename + count @skip(if: $weight) + weight @include(if: $weight) + } + incomplete { + __typename + count @skip(if: $weight) + weight @include(if: $weight) + } + } + } + } +} diff --git a/app/graphql/queries/epic/epic_children.query.graphql b/app/graphql/queries/epic/epic_children.query.graphql index c12778109d0..5ee27052f95 100644 --- a/app/graphql/queries/epic/epic_children.query.graphql +++ b/app/graphql/queries/epic/epic_children.query.graphql @@ -16,6 +16,10 @@ fragment RelatedTreeBaseEpic on Epic { adminEpic createEpic } + descendantWeightSum { + closedIssues + openedIssues + } descendantCounts { __typename openedEpics diff --git a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql index 959bf7dc91d..873ecc81466 100644 --- a/app/graphql/queries/pipelines/get_pipeline_details.query.graphql +++ b/app/graphql/queries/pipelines/get_pipeline_details.query.graphql @@ -27,6 +27,7 @@ query getPipelineDetails($projectPath: ID!, $iid: ID!) { __typename id iid + complete usesNeeds downstream { __typename diff --git a/app/graphql/resolvers/ci/runner_resolver.rb b/app/graphql/resolvers/ci/runner_resolver.rb new file mode 100644 index 00000000000..ca94e28b2e9 --- /dev/null +++ b/app/graphql/resolvers/ci/runner_resolver.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class RunnerResolver < BaseResolver + include LooksAhead + + type Types::Ci::RunnerType, null: true + extras [:lookahead] + description 'Runner information.' + + argument :id, + type: ::Types::GlobalIDType[::Ci::Runner], + required: true, + description: 'Runner ID.' + + def resolve_with_lookahead(id:) + find_runner(id: id) + end + + private + + def find_runner(id:) + runner_id = GitlabSchema.parse_gid(id, expected_type: ::Ci::Runner).model_id.to_i + preload_tag_list = lookahead.selects?(:tag_list) + + BatchLoader::GraphQL.for(runner_id).batch(key: { preload_tag_list: preload_tag_list }) do |ids, loader, batch| + results = ::Ci::Runner.id_in(ids) + results = results.with_tags if batch[:key][:preload_tag_list] + + results.each { |record| loader.call(record.id, record) } + end + end + end + end +end diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb new file mode 100644 index 00000000000..710706325cc --- /dev/null +++ b/app/graphql/resolvers/ci/runners_resolver.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class RunnersResolver < BaseResolver + type Types::Ci::RunnerType.connection_type, null: true + + argument :status, ::Types::Ci::RunnerStatusEnum, + required: false, + description: 'Filter runners by status.' + + argument :type, ::Types::Ci::RunnerTypeEnum, + required: false, + description: 'Filter runners by type.' + + argument :tag_list, [GraphQL::STRING_TYPE], + required: false, + description: 'Filter by tags associated with the runner (comma-separated or array).' + + argument :sort, ::Types::Ci::RunnerSortEnum, + required: false, + description: 'Sort order of results.' + + def resolve(**args) + ::Ci::RunnersFinder + .new(current_user: current_user, params: runners_finder_params(args)) + .execute + end + + private + + def runners_finder_params(params) + { + status_status: params[:status]&.to_s, + type_type: params[:type], + tag_name: params[:tag_list], + search: params[:search], + sort: params[:sort]&.to_s + }.compact + end + end + end +end diff --git a/app/graphql/resolvers/ci/template_resolver.rb b/app/graphql/resolvers/ci/template_resolver.rb new file mode 100644 index 00000000000..dd910116544 --- /dev/null +++ b/app/graphql/resolvers/ci/template_resolver.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class TemplateResolver < BaseResolver + type Types::Ci::TemplateType, null: true + + argument :name, GraphQL::STRING_TYPE, required: true, + description: 'Name of the CI/CD template to search for.' + + alias_method :project, :object + + def resolve(name: nil) + ::TemplateFinder.new(:gitlab_ci_ymls, project, name: name).execute + end + end + end +end diff --git a/app/graphql/resolvers/concerns/board_issue_filterable.rb b/app/graphql/resolvers/concerns/board_issue_filterable.rb index 3484a1cc4ba..88de69a3844 100644 --- a/app/graphql/resolvers/concerns/board_issue_filterable.rb +++ b/app/graphql/resolvers/concerns/board_issue_filterable.rb @@ -32,4 +32,4 @@ module BoardIssueFilterable end end -::BoardIssueFilterable.prepend_if_ee('::EE::Resolvers::BoardIssueFilterable') +::BoardIssueFilterable.prepend_mod_with('Resolvers::BoardIssueFilterable') diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb index 0ff3997f3bc..aa08d62c6a5 100644 --- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb +++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb @@ -7,57 +7,57 @@ module IssueResolverArguments include LooksAhead argument :iid, GraphQL::STRING_TYPE, - required: false, - description: 'IID of the issue. For example, "1".' + required: false, + description: 'IID of the issue. For example, "1".' argument :iids, [GraphQL::STRING_TYPE], - required: false, - description: 'List of IIDs of issues. For example, [1, 2].' + required: false, + description: 'List of IIDs of issues. For example, ["1", "2"].' argument :label_name, [GraphQL::STRING_TYPE, null: true], - required: false, - description: 'Labels applied to this issue.' + required: false, + description: 'Labels applied to this issue.' argument :milestone_title, [GraphQL::STRING_TYPE, null: true], - required: false, - description: 'Milestone applied to this issue.' + required: false, + description: 'Milestone applied to this issue.' argument :author_username, GraphQL::STRING_TYPE, - required: false, - description: 'Username of the author of the issue.' + required: false, + description: 'Username of the author of the issue.' argument :assignee_username, GraphQL::STRING_TYPE, - required: false, - description: 'Username of a user assigned to the issue.', - deprecated: { reason: 'Use `assigneeUsernames`', milestone: '13.11' } + required: false, + description: 'Username of a user assigned to the issue.', + deprecated: { reason: 'Use `assigneeUsernames`', milestone: '13.11' } argument :assignee_usernames, [GraphQL::STRING_TYPE], - required: false, - description: 'Usernames of users assigned to the issue.' + required: false, + description: 'Usernames of users assigned to the issue.' argument :assignee_id, GraphQL::STRING_TYPE, - required: false, - description: 'ID of a user assigned to the issues, "none" and "any" values are supported.' + required: false, + description: 'ID of a user assigned to the issues, "none" and "any" values are supported.' argument :created_before, Types::TimeType, - required: false, - description: 'Issues created before this date.' + required: false, + description: 'Issues created before this date.' argument :created_after, Types::TimeType, - required: false, - description: 'Issues created after this date.' + required: false, + description: 'Issues created after this date.' argument :updated_before, Types::TimeType, - required: false, - description: 'Issues updated before this date.' + required: false, + description: 'Issues updated before this date.' argument :updated_after, Types::TimeType, - required: false, - description: 'Issues updated after this date.' + required: false, + description: 'Issues updated after this date.' argument :closed_before, Types::TimeType, - required: false, - description: 'Issues closed before this date.' + required: false, + description: 'Issues closed before this date.' argument :closed_after, Types::TimeType, - required: false, - description: 'Issues closed after this date.' + required: false, + description: 'Issues closed after this date.' argument :search, GraphQL::STRING_TYPE, - required: false, - description: 'Search query for issue title or description.' + required: false, + description: 'Search query for issue title or description.' argument :types, [Types::IssueTypeEnum], - as: :issue_types, - description: 'Filter issues by the given issue types.', - required: false + as: :issue_types, + description: 'Filter issues by the given issue types.', + required: false argument :not, Types::Issues::NegatedIssueFilterInputType, - description: 'List of negated params.', + description: 'Negated arguments.', prepare: ->(negated_args, ctx) { negated_args.to_h }, required: false end diff --git a/app/graphql/resolvers/design_management/versions_resolver.rb b/app/graphql/resolvers/design_management/versions_resolver.rb index 619448cbc18..08b29d884b0 100644 --- a/app/graphql/resolvers/design_management/versions_resolver.rb +++ b/app/graphql/resolvers/design_management/versions_resolver.rb @@ -62,6 +62,7 @@ module Resolvers ::DesignManagement::VersionsFinder .new(design_or_collection, current_user, params) .execute + .with_author end def by_id(gid) diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb index df04e70e250..ee604e7b307 100644 --- a/app/graphql/resolvers/environments_resolver.rb +++ b/app/graphql/resolvers/environments_resolver.rb @@ -21,8 +21,8 @@ module Resolvers def resolve(**args) return unless project.present? - EnvironmentsFinder.new(project, context[:current_user], args).execute - rescue EnvironmentsFinder::InvalidStatesError => exception + Environments::EnvironmentsFinder.new(project, context[:current_user], args).execute + rescue Environments::EnvironmentsFinder::InvalidStatesError => exception raise Gitlab::Graphql::Errors::ArgumentError, exception.message end end diff --git a/app/graphql/resolvers/group_packages_resolver.rb b/app/graphql/resolvers/group_packages_resolver.rb index d441cd80249..d91fe84317d 100644 --- a/app/graphql/resolvers/group_packages_resolver.rb +++ b/app/graphql/resolvers/group_packages_resolver.rb @@ -1,8 +1,19 @@ # frozen_string_literal: true +# rubocop: disable Graphql/ResolverType module Resolvers - class GroupPackagesResolver < BaseResolver - type Types::Packages::PackageType.connection_type, null: true + class GroupPackagesResolver < PackagesBaseResolver + # The GraphQL type is defined in the extended class + + argument :sort, Types::Packages::PackageGroupSortEnum, + description: 'Sort packages by this criteria.', + required: false, + default_value: :created_desc + + GROUP_SORT_TO_PARAMS_MAP = SORT_TO_PARAMS_MAP.merge({ + project_path_desc: { order_by: 'project_path', sort: 'desc' }, + project_path_asc: { order_by: 'project_path', sort: 'asc' } + }).freeze def ready?(**args) context[self.class] ||= { executions: 0 } @@ -12,16 +23,11 @@ module Resolvers super end - def resolve(**args) + def resolve(sort:, **filters) return unless packages_available? - ::Packages::GroupPackagesFinder.new(current_user, object).execute - end - - private - - def packages_available? - ::Gitlab.config.packages.enabled + ::Packages::GroupPackagesFinder.new(current_user, object, filters.merge(GROUP_SORT_TO_PARAMS_MAP.fetch(sort))).execute end end end +# rubocop: enable Graphql/ResolverType diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 7a67f115abf..93e679b2d0c 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -55,4 +55,4 @@ module Resolvers end end -Resolvers::IssuesResolver.prepend_if_ee('::EE::Resolvers::IssuesResolver') +Resolvers::IssuesResolver.prepend_mod_with('Resolvers::IssuesResolver') diff --git a/app/graphql/resolvers/namespace_projects_resolver.rb b/app/graphql/resolvers/namespace_projects_resolver.rb index 7320c3ce141..86286a744bd 100644 --- a/app/graphql/resolvers/namespace_projects_resolver.rb +++ b/app/graphql/resolvers/namespace_projects_resolver.rb @@ -64,4 +64,4 @@ module Resolvers end end -Resolvers::NamespaceProjectsResolver.prepend_if_ee('::EE::Resolvers::NamespaceProjectsResolver') +Resolvers::NamespaceProjectsResolver.prepend_mod_with('Resolvers::NamespaceProjectsResolver') diff --git a/app/graphql/resolvers/packages_base_resolver.rb b/app/graphql/resolvers/packages_base_resolver.rb new file mode 100644 index 00000000000..3378cc32c9f --- /dev/null +++ b/app/graphql/resolvers/packages_base_resolver.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Resolvers + class PackagesBaseResolver < BaseResolver + type Types::Packages::PackageType.connection_type, null: true + + argument :sort, Types::Packages::PackageSortEnum, + description: 'Sort packages by this criteria.', + required: false, + default_value: :created_desc + + argument :package_name, GraphQL::STRING_TYPE, + description: 'Search a package by name.', + required: false, + default_value: nil + + argument :package_type, Types::Packages::PackageTypeEnum, + description: 'Filter a package by type.', + required: false, + default_value: nil + + argument :status, Types::Packages::PackageStatusEnum, + description: 'Filter a package by status.', + required: false, + default_value: nil + + argument :include_versionless, GraphQL::BOOLEAN_TYPE, + description: 'Include versionless packages.', + required: false, + default_value: false + + SORT_TO_PARAMS_MAP = { + created_desc: { order_by: 'created', sort: 'desc' }, + created_asc: { order_by: 'created', sort: 'asc' }, + name_desc: { order_by: 'name', sort: 'desc' }, + name_asc: { order_by: 'name', sort: 'asc' }, + version_desc: { order_by: 'version', sort: 'desc' }, + version_asc: { order_by: 'version', sort: 'asc' }, + type_desc: { order_by: 'type', sort: 'desc' }, + type_asc: { order_by: 'type', sort: 'asc' } + }.freeze + + def resolve + raise NotImplementedError + end + + private + + def packages_available? + ::Gitlab.config.packages.enabled + end + end +end diff --git a/app/graphql/resolvers/project_packages_resolver.rb b/app/graphql/resolvers/project_packages_resolver.rb index 288e14b41d0..6d66c2fe460 100644 --- a/app/graphql/resolvers/project_packages_resolver.rb +++ b/app/graphql/resolvers/project_packages_resolver.rb @@ -1,19 +1,15 @@ # frozen_string_literal: true +# rubocop: disable Graphql/ResolverType module Resolvers - class ProjectPackagesResolver < BaseResolver - type Types::Packages::PackageType.connection_type, null: true + class ProjectPackagesResolver < PackagesBaseResolver + # The GraphQL type is defined in the extended class - def resolve(**args) + def resolve(sort:, **filters) return unless packages_available? - ::Packages::PackagesFinder.new(object).execute - end - - private - - def packages_available? - ::Gitlab.config.packages.enabled + ::Packages::PackagesFinder.new(object, filters.merge(SORT_TO_PARAMS_MAP.fetch(sort))).execute end end end +# rubocop: enable Graphql/ResolverType diff --git a/app/graphql/resolvers/projects/services_resolver.rb b/app/graphql/resolvers/projects/services_resolver.rb index ec31a7dbe6d..db3037ec591 100644 --- a/app/graphql/resolvers/projects/services_resolver.rb +++ b/app/graphql/resolvers/projects/services_resolver.rb @@ -21,7 +21,7 @@ module Resolvers alias_method :project, :object def resolve(active: nil, type: nil) - servs = project.services + servs = project.integrations servs = servs.by_active_flag(active) unless active.nil? servs = servs.by_type(type) unless type.blank? servs diff --git a/app/graphql/resolvers/release_resolver.rb b/app/graphql/resolvers/release_resolver.rb index 20ef01f8593..67ff5fed0bb 100644 --- a/app/graphql/resolvers/release_resolver.rb +++ b/app/graphql/resolvers/release_resolver.rb @@ -15,8 +15,6 @@ module Resolvers end def resolve(tag_name:) - return unless Feature.enabled?(:graphql_release_data, project, default_enabled: true) - ReleasesFinder.new( project, current_user, diff --git a/app/graphql/resolvers/releases_resolver.rb b/app/graphql/resolvers/releases_resolver.rb index 01c1e9b11e7..358f3c33836 100644 --- a/app/graphql/resolvers/releases_resolver.rb +++ b/app/graphql/resolvers/releases_resolver.rb @@ -23,8 +23,6 @@ module Resolvers }.freeze def resolve(sort:) - return unless Feature.enabled?(:graphql_release_data, project, default_enabled: true) - ReleasesFinder.new( project, current_user, diff --git a/app/graphql/resolvers/repository_branch_names_resolver.rb b/app/graphql/resolvers/repository_branch_names_resolver.rb index 45cfe229b2f..c0a5ea0366f 100644 --- a/app/graphql/resolvers/repository_branch_names_resolver.rb +++ b/app/graphql/resolvers/repository_branch_names_resolver.rb @@ -10,8 +10,16 @@ module Resolvers required: true, description: 'The pattern to search for branch names by.' - def resolve(search_pattern:) - Repositories::BranchNamesFinder.new(object, search: search_pattern).execute + argument :offset, GraphQL::INT_TYPE, + required: true, + description: 'The number of branch names to skip.' + + argument :limit, GraphQL::INT_TYPE, + required: true, + description: 'The number of branch names to return.' + + def resolve(search_pattern:, offset:, limit:) + Repositories::BranchNamesFinder.new(object, offset: offset, limit: limit, search: search_pattern).execute end end end diff --git a/app/graphql/subscriptions/base_subscription.rb b/app/graphql/subscriptions/base_subscription.rb new file mode 100644 index 00000000000..5f7931787df --- /dev/null +++ b/app/graphql/subscriptions/base_subscription.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Subscriptions + class BaseSubscription < GraphQL::Schema::Subscription + object_class Types::BaseObject + field_class Types::BaseField + + def initialize(object:, context:, field:) + super + + # Reset user so that we don't use a stale user for authorization + current_user.reset if current_user + end + + def authorized?(*) + raise NotImplementedError + end + + private + + def unauthorized! + unsubscribe if context.query.subscription_update? + + raise GraphQL::ExecutionError, 'Unauthorized subscription' + end + + def current_user + context[:current_user] + end + end +end diff --git a/app/graphql/subscriptions/issuable_updated.rb b/app/graphql/subscriptions/issuable_updated.rb new file mode 100644 index 00000000000..c1d82bfcf9c --- /dev/null +++ b/app/graphql/subscriptions/issuable_updated.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Subscriptions + class IssuableUpdated < BaseSubscription + include Gitlab::Graphql::Laziness + + payload_type Types::IssuableType + + argument :issuable_id, Types::GlobalIDType[Issuable], + required: true, + description: 'ID of the issuable.' + + def subscribe(issuable_id:) + nil + end + + def authorized?(issuable_id:) + # TODO: remove this check when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + raise Gitlab::Graphql::Errors::ArgumentError, 'Invalid IssuableID' unless issuable_id.is_a?(GlobalID) + + issuable = force(GitlabSchema.find_by_gid(issuable_id)) + + unauthorized! unless issuable && Ability.allowed?(current_user, :"read_#{issuable.to_ability_name}", issuable) + + true + end + end +end diff --git a/app/graphql/types/access_level_enum.rb b/app/graphql/types/access_level_enum.rb index b7eb35ddfc9..299952e4685 100644 --- a/app/graphql/types/access_level_enum.rb +++ b/app/graphql/types/access_level_enum.rb @@ -5,12 +5,12 @@ module Types graphql_name 'AccessLevelEnum' description 'Access level to a resource' - value 'NO_ACCESS', value: Gitlab::Access::NO_ACCESS, description: 'No access' - value 'MINIMAL_ACCESS', value: Gitlab::Access::MINIMAL_ACCESS, description: 'Minimal access' - value 'GUEST', value: Gitlab::Access::GUEST, description: 'Guest access' - value 'REPORTER', value: Gitlab::Access::REPORTER, description: 'Reporter access' - value 'DEVELOPER', value: Gitlab::Access::DEVELOPER, description: 'Developer access' - value 'MAINTAINER', value: Gitlab::Access::MAINTAINER, description: 'Maintainer access' - value 'OWNER', value: Gitlab::Access::OWNER, description: 'Owner access' + value 'NO_ACCESS', value: Gitlab::Access::NO_ACCESS, description: 'No access.' + value 'MINIMAL_ACCESS', value: Gitlab::Access::MINIMAL_ACCESS, description: 'Minimal access.' + value 'GUEST', value: Gitlab::Access::GUEST, description: 'Guest access.' + value 'REPORTER', value: Gitlab::Access::REPORTER, description: 'Reporter access.' + value 'DEVELOPER', value: Gitlab::Access::DEVELOPER, description: 'Developer access.' + value 'MAINTAINER', value: Gitlab::Access::MAINTAINER, description: 'Maintainer access.' + value 'OWNER', value: Gitlab::Access::OWNER, description: 'Owner access.' end end diff --git a/app/graphql/types/alert_management/http_integration_type.rb b/app/graphql/types/alert_management/http_integration_type.rb index 0d5bb50a77c..bba9cb1bbfc 100644 --- a/app/graphql/types/alert_management/http_integration_type.rb +++ b/app/graphql/types/alert_management/http_integration_type.rb @@ -21,4 +21,4 @@ module Types end end -Types::AlertManagement::HttpIntegrationType.prepend_ee_mod +Types::AlertManagement::HttpIntegrationType.prepend_mod diff --git a/app/graphql/types/alert_management/status_enum.rb b/app/graphql/types/alert_management/status_enum.rb index 9d2c7316254..32a578cb155 100644 --- a/app/graphql/types/alert_management/status_enum.rb +++ b/app/graphql/types/alert_management/status_enum.rb @@ -7,7 +7,7 @@ module Types description 'Alert status values' ::AlertManagement::Alert.status_names.each do |status| - value status.to_s.upcase, value: status, description: "#{status.to_s.titleize} status" + value status.to_s.upcase, value: status, description: "#{::AlertManagement::Alert::STATUS_DESCRIPTIONS[status]}." end end end diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb index ff9a5a0611d..536a32f2bdd 100644 --- a/app/graphql/types/base_argument.rb +++ b/app/graphql/types/base_argument.rb @@ -4,10 +4,11 @@ module Types class BaseArgument < GraphQL::Schema::Argument include GitlabStyleDeprecations - attr_reader :deprecation + attr_reader :deprecation, :doc_reference def initialize(*args, **kwargs, &block) @deprecation = gitlab_deprecation(kwargs) + @doc_reference = kwargs.delete(:see) super(*args, **kwargs, &block) end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb index 518a902a5d7..7ef1cbddbd9 100644 --- a/app/graphql/types/base_enum.rb +++ b/app/graphql/types/base_enum.rb @@ -17,6 +17,9 @@ module Types # declarative_enum MyDeclarativeEnum # end # + # Disabling descriptions rubocop for a false positive here + # rubocop: disable Graphql/Descriptions + # def declarative_enum(enum_mod, use_name: true, use_description: true) graphql_name(enum_mod.name) if use_name description(enum_mod.description) if use_description @@ -25,6 +28,7 @@ module Types value(key.to_s.upcase, **content) end end + # rubocop: enable Graphql/Descriptions # Helper to define an enum member for each element of a Rails AR enum def from_rails_enum(enum, description:) diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 7c939f94dde..47caf83eb1c 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -8,10 +8,11 @@ module Types DEFAULT_COMPLEXITY = 1 - attr_reader :deprecation + attr_reader :deprecation, :doc_reference def initialize(**kwargs, &block) @calls_gitaly = !!kwargs.delete(:calls_gitaly) + @doc_reference = kwargs.delete(:see) @constant_complexity = kwargs[:complexity].is_a?(Integer) && kwargs[:complexity] > 0 @requires_argument = !!kwargs.delete(:requires_argument) @authorize = Array.wrap(kwargs.delete(:authorize)) diff --git a/app/graphql/types/blob_viewer_type.rb b/app/graphql/types/blob_viewer_type.rb new file mode 100644 index 00000000000..8d863c32bc7 --- /dev/null +++ b/app/graphql/types/blob_viewer_type.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Types + class BlobViewerType < BaseObject # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'BlobViewer' + description 'Represents how the blob content should be displayed' + + field :type, Types::BlobViewers::TypeEnum, + description: 'Type of blob viewer.', + null: false + + field :load_async, GraphQL::BOOLEAN_TYPE, + description: 'Shows whether the blob content is loaded asynchronously.', + null: false + + field :collapsed, GraphQL::BOOLEAN_TYPE, + description: 'Shows whether the blob should be displayed collapsed.', + method: :collapsed?, + null: false + + field :too_large, GraphQL::BOOLEAN_TYPE, + description: 'Shows whether the blob is too large to be displayed.', + method: :too_large?, + null: false + + field :render_error, GraphQL::STRING_TYPE, + description: 'Error rendering the blob content.', + null: true + + field :file_type, GraphQL::STRING_TYPE, + description: 'Content file type.', + method: :partial_name, + null: false + + field :loading_partial_name, GraphQL::STRING_TYPE, + description: 'Loading partial name.', + null: false + + def collapsed + !!object&.collapsed? + end + + def too_large + !!object&.too_large? + end + end +end diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb index f215aa255de..dc10716dcb0 100644 --- a/app/graphql/types/board_list_type.rb +++ b/app/graphql/types/board_list_type.rb @@ -50,4 +50,4 @@ module Types # rubocop: enable Graphql/AuthorizeTypes end -Types::BoardListType.prepend_if_ee('::EE::Types::BoardListType') +Types::BoardListType.prepend_mod_with('Types::BoardListType') diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb index 42d8eecc366..292809b0d64 100644 --- a/app/graphql/types/board_type.rb +++ b/app/graphql/types/board_type.rb @@ -41,4 +41,4 @@ module Types end end -Types::BoardType.prepend_if_ee('::EE::Types::BoardType') +Types::BoardType.prepend_mod_with('Types::BoardType') diff --git a/app/graphql/types/boards/board_issue_input_base_type.rb b/app/graphql/types/boards/board_issue_input_base_type.rb index 7cf2dcb9c82..633221e61d1 100644 --- a/app/graphql/types/boards/board_issue_input_base_type.rb +++ b/app/graphql/types/boards/board_issue_input_base_type.rb @@ -4,6 +4,10 @@ module Types module Boards # rubocop: disable Graphql/AuthorizeTypes class BoardIssueInputBaseType < BoardIssuableInputBaseType + argument :iids, [GraphQL::STRING_TYPE], + required: false, + description: 'List of IIDs of issues. For example ["1", "2"].' + argument :milestone_title, GraphQL::STRING_TYPE, required: false, description: 'Filter by milestone title.' @@ -19,4 +23,4 @@ module Types end end -Types::Boards::BoardIssueInputBaseType.prepend_if_ee('::EE::Types::Boards::BoardIssueInputBaseType') +Types::Boards::BoardIssueInputBaseType.prepend_mod_with('Types::Boards::BoardIssueInputBaseType') diff --git a/app/graphql/types/boards/board_issue_input_type.rb b/app/graphql/types/boards/board_issue_input_type.rb index 8c0e37e5cb7..7580b0378fe 100644 --- a/app/graphql/types/boards/board_issue_input_type.rb +++ b/app/graphql/types/boards/board_issue_input_type.rb @@ -8,10 +8,7 @@ module Types argument :not, NegatedBoardIssueInputType, required: false, prepare: ->(negated_args, ctx) { negated_args.to_h }, - description: <<~MD - List of negated arguments. - Warning: this argument is experimental and a subject to change in future. - MD + description: 'List of negated arguments.' argument :search, GraphQL::STRING_TYPE, required: false, @@ -24,4 +21,4 @@ module Types end end -Types::Boards::BoardIssueInputType.prepend_if_ee('::EE::Types::Boards::BoardIssueInputType') +Types::Boards::BoardIssueInputType.prepend_mod_with('Types::Boards::BoardIssueInputType') diff --git a/app/graphql/types/boards/negated_board_issue_input_type.rb b/app/graphql/types/boards/negated_board_issue_input_type.rb index a0fab2ae969..834d94d4de6 100644 --- a/app/graphql/types/boards/negated_board_issue_input_type.rb +++ b/app/graphql/types/boards/negated_board_issue_input_type.rb @@ -7,4 +7,4 @@ module Types end end -Types::Boards::NegatedBoardIssueInputType.prepend_if_ee('::EE::Types::Boards::NegatedBoardIssueInputType') +Types::Boards::NegatedBoardIssueInputType.prepend_mod_with('Types::Boards::NegatedBoardIssueInputType') diff --git a/app/graphql/types/ci/code_quality_degradation_severity_enum.rb b/app/graphql/types/ci/code_quality_degradation_severity_enum.rb new file mode 100644 index 00000000000..742ac922198 --- /dev/null +++ b/app/graphql/types/ci/code_quality_degradation_severity_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + class CodeQualityDegradationSeverityEnum < BaseEnum + graphql_name 'CodeQualityDegradationSeverity' + + ::Gitlab::Ci::Reports::CodequalityReports::SEVERITY_PRIORITIES.keys.each do |status| + value status.upcase, + description: "Code Quality degradation has a status of #{status}.", + value: status + end + end + end +end diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb index 94a256fed3d..5ed4d823aee 100644 --- a/app/graphql/types/ci/job_type.rb +++ b/app/graphql/types/ci/job_type.rb @@ -8,6 +8,8 @@ module Types connection_type_class(Types::CountableConnectionType) + expose_permissions Types::PermissionTypes::Ci::Job + field :id, ::Types::GlobalIDType[::CommitStatus].as('JobID'), null: true, description: 'ID of the job.' field :pipeline, Types::Ci::PipelineType, null: true, @@ -23,7 +25,7 @@ module Types field :stage, Types::Ci::StageType, null: true, description: 'Stage of the job.' field :allow_failure, ::GraphQL::BOOLEAN_TYPE, null: false, - description: 'Whether this job is allowed to fail.' + description: 'Whether the job is allowed to fail.' field :duration, GraphQL::INT_TYPE, null: true, description: 'Duration of the job in seconds.' field :tags, [GraphQL::STRING_TYPE], null: true, @@ -41,6 +43,12 @@ module Types field :scheduled_at, Types::TimeType, null: true, description: 'Schedule for the build.' + # Life-cycle durations: + field :queued_duration, + type: Types::DurationType, + null: true, + description: 'How long the job was enqueued before starting.' + field :detailed_status, Types::Ci::DetailedStatusType, null: true, description: 'Detailed status of the job.' field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true, @@ -63,8 +71,16 @@ module Types description: 'Indicates the job can be canceled.' field :active, GraphQL::BOOLEAN_TYPE, null: false, method: :active?, description: 'Indicates the job is active.' + field :stuck, GraphQL::BOOLEAN_TYPE, null: false, method: :stuck?, + description: 'Indicates the job is stuck.' field :coverage, GraphQL::FLOAT_TYPE, null: true, description: 'Coverage level of the job.' + field :created_by_tag, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Whether the job was created by a tag.' + field :manual_job, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Whether the job has a manual action.' + field :triggered, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Whether the job was triggered.' def pipeline Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Pipeline, object.pipeline_id).find @@ -123,6 +139,18 @@ module Types def coverage object&.coverage end + + def created_by_tag + object.tag? + end + + def manual_job + object.try(:action?) + end + + def triggered + object.try(:trigger_request) + end end end end diff --git a/app/graphql/types/ci/pipeline_status_enum.rb b/app/graphql/types/ci/pipeline_status_enum.rb index e0b2020dcc1..2800454a999 100644 --- a/app/graphql/types/ci/pipeline_status_enum.rb +++ b/app/graphql/types/ci/pipeline_status_enum.rb @@ -5,7 +5,7 @@ module Types class PipelineStatusEnum < BaseEnum ::Ci::Pipeline.all_state_names.each do |state_symbol| value state_symbol.to_s.upcase, - description: ::Ci::Pipeline::STATUSES_DESCRIPTION[state_symbol], + description: "#{::Ci::Pipeline::STATUSES_DESCRIPTION[state_symbol]}.", value: state_symbol.to_s end end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index 2e83f6c1f5a..2eeddaca6ba 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -24,6 +24,9 @@ module Types field :before_sha, GraphQL::STRING_TYPE, null: true, description: 'Base SHA of the source branch.' + field :complete, GraphQL::BOOLEAN_TYPE, null: false, method: :complete?, + description: 'Indicates if a pipeline is complete.' + field :status, PipelineStatusEnum, null: false, description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})" @@ -39,6 +42,9 @@ module Types field :duration, GraphQL::INT_TYPE, null: true, description: 'Duration of the pipeline in seconds.' + field :queued_duration, Types::DurationType, null: true, + description: 'How long the pipeline was queued before starting.' + field :coverage, GraphQL::FLOAT_TYPE, null: true, description: 'Coverage percentage.' @@ -57,12 +63,17 @@ module Types field :committed_at, Types::TimeType, null: true, description: "Timestamp of the pipeline's commit." - field :stages, Types::Ci::StageType.connection_type, null: true, + field :stages, + type: Types::Ci::StageType.connection_type, + null: true, + authorize: :read_commit_status, description: 'Stages of the pipeline.', extras: [:lookahead], resolver: Resolvers::Ci::PipelineStagesResolver - field :user, Types::UserType, null: true, + field :user, + type: Types::UserType, + null: true, description: 'Pipeline user.' field :retryable, GraphQL::BOOLEAN_TYPE, @@ -78,12 +89,14 @@ module Types field :jobs, ::Types::Ci::JobType.connection_type, null: true, + authorize: :read_commit_status, description: 'Jobs belonging to the pipeline.', resolver: ::Resolvers::Ci::JobsResolver field :job, type: ::Types::Ci::JobType, null: true, + authorize: :read_commit_status, description: 'A specific job in this pipeline, either by name or ID.' do argument :id, type: ::Types::GlobalIDType[::CommitStatus], @@ -95,7 +108,10 @@ module Types description: 'Name of the job.' end - field :source_job, Types::Ci::JobType, null: true, + field :source_job, + type: Types::Ci::JobType, + null: true, + authorize: :read_commit_status, description: 'Job where pipeline was triggered from.' field :downstream, Types::Ci::PipelineType.connection_type, null: true, @@ -166,4 +182,4 @@ module Types end end -Types::Ci::PipelineType.prepend_if_ee('::EE::Types::Ci::PipelineType') +Types::Ci::PipelineType.prepend_mod_with('Types::Ci::PipelineType') diff --git a/app/graphql/types/ci/runner_access_level_enum.rb b/app/graphql/types/ci/runner_access_level_enum.rb new file mode 100644 index 00000000000..e98f80336f1 --- /dev/null +++ b/app/graphql/types/ci/runner_access_level_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + class RunnerAccessLevelEnum < BaseEnum + graphql_name 'CiRunnerAccessLevel' + + ::Ci::Runner.access_levels.keys.each do |type| + value type.upcase, + description: "A runner that is #{type.tr('_', ' ')}.", + value: type + end + end + end +end diff --git a/app/graphql/types/ci/runner_sort_enum.rb b/app/graphql/types/ci/runner_sort_enum.rb new file mode 100644 index 00000000000..550e870316a --- /dev/null +++ b/app/graphql/types/ci/runner_sort_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Ci + class RunnerSortEnum < BaseEnum + graphql_name 'CiRunnerSort' + description 'Values for sorting runners' + + value 'CONTACTED_ASC', 'Ordered by contacted_at in ascending order.', value: :contacted_asc + value 'CREATED_DESC', 'Ordered by created_date in descending order.', value: :created_date + end + end +end diff --git a/app/graphql/types/ci/runner_status_enum.rb b/app/graphql/types/ci/runner_status_enum.rb new file mode 100644 index 00000000000..ad69175e44a --- /dev/null +++ b/app/graphql/types/ci/runner_status_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + class RunnerStatusEnum < BaseEnum + graphql_name 'CiRunnerStatus' + + ::Ci::Runner::AVAILABLE_STATUSES.each do |status| + value status.to_s.upcase, + description: "A runner that is #{status.to_s.tr('_', ' ')}.", + value: status.to_sym + end + end + end +end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb new file mode 100644 index 00000000000..3abed7289d5 --- /dev/null +++ b/app/graphql/types/ci/runner_type.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Types + module Ci + class RunnerType < BaseObject + graphql_name 'CiRunner' + authorize :read_runner + + field :id, ::Types::GlobalIDType[::Ci::Runner], null: false, + description: 'ID of the runner.' + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the runner.' + field :contacted_at, Types::TimeType, null: true, + description: 'Last contact from the runner.', + method: :contacted_at + field :maximum_timeout, GraphQL::INT_TYPE, null: true, + description: 'Maximum timeout (in seconds) for jobs processed by the runner.' + field :access_level, ::Types::Ci::RunnerAccessLevelEnum, null: false, + description: 'Access level of the runner.' + field :active, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates the runner is allowed to receive jobs.' + field :status, ::Types::Ci::RunnerStatusEnum, null: false, + description: 'Status of the runner.' + field :version, GraphQL::STRING_TYPE, null: false, + description: 'Version of the runner.' + field :short_sha, GraphQL::STRING_TYPE, null: true, + description: %q(First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID.) + field :revision, GraphQL::STRING_TYPE, null: false, + description: 'Revision of the runner.' + field :locked, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates the runner is locked.' + field :run_untagged, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates the runner is able to run untagged jobs.' + field :ip_address, GraphQL::STRING_TYPE, null: false, + description: 'IP address of the runner.' + field :runner_type, ::Types::Ci::RunnerTypeEnum, null: false, + description: 'Type of the runner.' + field :tag_list, [GraphQL::STRING_TYPE], null: true, + description: 'Tags associated with the runner.' + end + end +end diff --git a/app/graphql/types/ci/runner_type_enum.rb b/app/graphql/types/ci/runner_type_enum.rb new file mode 100644 index 00000000000..f771635f4ed --- /dev/null +++ b/app/graphql/types/ci/runner_type_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + class RunnerTypeEnum < BaseEnum + graphql_name 'CiRunnerType' + + ::Ci::Runner.runner_types.keys.each do |type| + value type.upcase, + description: "A runner that is #{type.tr('_', ' ')}.", + value: type + end + end + end +end diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb index 56b4f248697..1be9e3192a9 100644 --- a/app/graphql/types/ci/stage_type.rb +++ b/app/graphql/types/ci/stage_type.rb @@ -2,20 +2,26 @@ module Types module Ci - # rubocop: disable Graphql/AuthorizeTypes class StageType < BaseObject graphql_name 'CiStage' + authorize :read_commit_status - field :name, GraphQL::STRING_TYPE, null: true, - description: 'Name of the stage.' - field :groups, Ci::GroupType.connection_type, null: true, - extras: [:lookahead], - description: 'Group of jobs for the stage.' - field :detailed_status, Types::Ci::DetailedStatusType, null: true, - description: 'Detailed status of the stage.' - field :jobs, Ci::JobType.connection_type, null: true, - description: 'Jobs for the stage.', - method: 'latest_statuses' + field :name, + type: GraphQL::STRING_TYPE, + null: true, + description: 'Name of the stage.' + field :groups, + type: Ci::GroupType.connection_type, + null: true, + extras: [:lookahead], + description: 'Group of jobs for the stage.' + field :detailed_status, Types::Ci::DetailedStatusType, + null: true, + description: 'Detailed status of the stage.' + field :jobs, Ci::JobType.connection_type, + null: true, + description: 'Jobs for the stage.', + method: 'latest_statuses' def detailed_status object.detailed_status(current_user) @@ -37,33 +43,6 @@ module Types key = indexed[stage_id] groups = ::Ci::Group.fabricate(project, key.stage, statuses) - if Feature.enabled?(:ci_no_empty_groups, project) - groups.each do |group| - rejected = group.jobs.reject { |job| Ability.allowed?(current_user, :read_commit_status, job) } - group.jobs.select! { |job| Ability.allowed?(current_user, :read_commit_status, job) } - next unless group.jobs.empty? - - exc = StandardError.new('Empty Ci::Group') - traces = rejected.map do |job| - trace = [] - policy = Ability.policy_for(current_user, job) - policy.debug(:read_commit_status, trace) - trace - end - extra = { - current_user_id: current_user&.id, - project_id: project.id, - pipeline_id: pl.id, - stage_id: stage_id, - group_name: group.name, - rejected_job_ids: rejected.map(&:id), - rejected_traces: traces - } - Gitlab::ErrorTracking.track_exception(exc, extra) - end - groups.reject! { |group| group.jobs.empty? } - end - loader.call(key, groups) end end diff --git a/app/graphql/types/ci/template_type.rb b/app/graphql/types/ci/template_type.rb new file mode 100644 index 00000000000..5f07fa16928 --- /dev/null +++ b/app/graphql/types/ci/template_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class TemplateType < BaseObject + graphql_name 'CiTemplate' + description 'GitLab CI/CD configuration template.' + + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Name of the CI template.' + field :content, GraphQL::STRING_TYPE, null: false, + description: 'Contents of the CI template.' + end + end +end diff --git a/app/graphql/types/container_expiration_policy_cadence_enum.rb b/app/graphql/types/container_expiration_policy_cadence_enum.rb index bb8bdf2197b..ac923f64b52 100644 --- a/app/graphql/types/container_expiration_policy_cadence_enum.rb +++ b/app/graphql/types/container_expiration_policy_cadence_enum.rb @@ -11,7 +11,7 @@ module Types }.freeze ::ContainerExpirationPolicy.cadence_options.each do |option, description| - value OPTIONS_MAPPING[option], description, value: option.to_s + value OPTIONS_MAPPING[option], description: description, value: option.to_s end end end diff --git a/app/graphql/types/container_expiration_policy_keep_enum.rb b/app/graphql/types/container_expiration_policy_keep_enum.rb index 7632df61092..ca6fbbcf5ae 100644 --- a/app/graphql/types/container_expiration_policy_keep_enum.rb +++ b/app/graphql/types/container_expiration_policy_keep_enum.rb @@ -12,7 +12,7 @@ module Types }.freeze ::ContainerExpirationPolicy.keep_n_options.each do |option, description| - value OPTIONS_MAPPING[option], description, value: option + value OPTIONS_MAPPING[option], description: description, value: option end end end diff --git a/app/graphql/types/container_expiration_policy_older_than_enum.rb b/app/graphql/types/container_expiration_policy_older_than_enum.rb index da70534b0d7..7364910f8cd 100644 --- a/app/graphql/types/container_expiration_policy_older_than_enum.rb +++ b/app/graphql/types/container_expiration_policy_older_than_enum.rb @@ -10,7 +10,7 @@ module Types }.freeze ::ContainerExpirationPolicy.older_than_options.each do |option, description| - value OPTIONS_MAPPING[option], description, value: option.to_s + value OPTIONS_MAPPING[option], description: description, value: option.to_s end end end diff --git a/app/graphql/types/design_management/version_type.rb b/app/graphql/types/design_management/version_type.rb index 4bc71aef0f4..265d6185110 100644 --- a/app/graphql/types/design_management/version_type.rb +++ b/app/graphql/types/design_management/version_type.rb @@ -32,6 +32,10 @@ module Types null: false, description: 'A particular design as of this version, provided it is visible at this version.', resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver.single + + field :author, Types::UserType, null: false, description: 'Author of the version.' + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of when the version was created.' end end end diff --git a/app/graphql/types/duration_type.rb b/app/graphql/types/duration_type.rb new file mode 100644 index 00000000000..260a2975ec1 --- /dev/null +++ b/app/graphql/types/duration_type.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Types + class DurationType < BaseScalar + graphql_name 'Duration' + description <<~DESC + Duration between two instants, represented as a fractional number of seconds. + + For example: 12.3334 + DESC + + def self.coerce_input(value, ctx) + case value + when Float + value + when Integer + value.to_f + when NilClass + raise GraphQL::CoercionError, 'Cannot be nil' + else + raise GraphQL::CoercionError, "Expected number: got #{value.class}" + end + end + + def self.coerce_result(value, ctx) + value.to_f + end + end +end diff --git a/app/graphql/types/group_member_relation_enum.rb b/app/graphql/types/group_member_relation_enum.rb index aa2e73d4944..54e2a175f33 100644 --- a/app/graphql/types/group_member_relation_enum.rb +++ b/app/graphql/types/group_member_relation_enum.rb @@ -6,7 +6,7 @@ module Types description 'Group member relation' ::GroupMembersFinder::RELATIONS.each do |member_relation| - value member_relation.to_s.upcase, value: member_relation, description: "#{member_relation.to_s.titleize} members" + value member_relation.to_s.upcase, value: member_relation, description: "#{::GroupMembersFinder::RELATIONS_DESCRIPTIONS[member_relation]}." end end end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index a44281b2bdf..27f4ae47c41 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -144,7 +144,7 @@ module Types resolver: Resolvers::GroupLabelsResolver field :timelogs, ::Types::TimelogType.connection_type, null: false, - description: 'Time logged on issues in the group and its subgroups.', + description: 'Time logged on issues and merge requests in the group and its subgroups.', extras: [:lookahead], complexity: 5, resolver: ::Resolvers::TimelogResolver @@ -169,4 +169,4 @@ module Types end end -Types::GroupType.prepend_if_ee('EE::Types::GroupType') +Types::GroupType.prepend_mod_with('Types::GroupType') diff --git a/app/graphql/types/issuable_type.rb b/app/graphql/types/issuable_type.rb new file mode 100644 index 00000000000..6ca74087f8a --- /dev/null +++ b/app/graphql/types/issuable_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + class IssuableType < BaseUnion + graphql_name 'Issuable' + description 'Represents an issuable.' + + possible_types Types::IssueType, Types::MergeRequestType + + def self.resolve_type(object, context) + case object + when Issue + Types::IssueType + when MergeRequest + Types::MergeRequestType + else + raise 'Unsupported issuable type' + end + end + end +end + +Types::IssuableType.prepend_mod_with('Types::IssuableType') diff --git a/app/graphql/types/issue_connection_type.rb b/app/graphql/types/issue_connection_type.rb index 2e0f05f741e..7abb2253fac 100644 --- a/app/graphql/types/issue_connection_type.rb +++ b/app/graphql/types/issue_connection_type.rb @@ -6,4 +6,4 @@ module Types end end -Types::IssueConnectionType.prepend_if_ee('::EE::Types::IssueConnectionType') +Types::IssueConnectionType.prepend_mod_with('Types::IssueConnectionType') diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb index bf900fe3525..e730a51b60e 100644 --- a/app/graphql/types/issue_sort_enum.rb +++ b/app/graphql/types/issue_sort_enum.rb @@ -13,4 +13,4 @@ module Types end end -Types::IssueSortEnum.prepend_if_ee('::EE::Types::IssueSortEnum') +Types::IssueSortEnum.prepend_mod_with('Types::IssueSortEnum') diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 34c824fe9fb..0ccd1e2cebd 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -153,4 +153,4 @@ module Types end end -Types::IssueType.prepend_if_ee('::EE::Types::IssueType') +Types::IssueType.prepend_mod_with('Types::IssueType') diff --git a/app/graphql/types/issues/negated_issue_filter_input_type.rb b/app/graphql/types/issues/negated_issue_filter_input_type.rb index 10bf6f21792..8a2e75ed9ba 100644 --- a/app/graphql/types/issues/negated_issue_filter_input_type.rb +++ b/app/graphql/types/issues/negated_issue_filter_input_type.rb @@ -24,4 +24,4 @@ module Types end end -Types::Issues::NegatedIssueFilterInputType.prepend_if_ee('::EE::Types::Issues::NegatedIssueFilterInputType') +Types::Issues::NegatedIssueFilterInputType.prepend_mod_with('Types::Issues::NegatedIssueFilterInputType') diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb index 4e8718a80da..cb6b0312aa3 100644 --- a/app/graphql/types/label_type.rb +++ b/app/graphql/types/label_type.rb @@ -23,5 +23,7 @@ module Types description: 'When this label was created.' field :updated_at, Types::TimeType, null: false, description: 'When this label was last updated.' + field :remove_on_close, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Whether the label should be removed from an issue when the issue is closed.' end end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index c8ccf9d8aff..4eeeaa4f5d0 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -54,6 +54,9 @@ module Types field :target_branch, GraphQL::STRING_TYPE, null: false, description: 'Target branch of the merge request.' field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false, + deprecated: { reason: 'Use `draft`', milestone: '13.12' }, + description: 'Indicates if the merge request is a draft.' + field :draft, GraphQL::BOOLEAN_TYPE, method: :draft?, null: false, description: 'Indicates if the merge request is a draft.' field :merge_when_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS).' @@ -130,7 +133,10 @@ module Types field :milestone, Types::MilestoneType, null: true, description: 'The milestone of the merge request.' - field :assignees, Types::UserType.connection_type, null: true, complexity: 5, + field :assignees, + type: Types::MergeRequests::AssigneeType.connection_type, + null: true, + complexity: 5, description: 'Assignees of the merge request.' field :reviewers, type: Types::MergeRequests::ReviewerType.connection_type, @@ -257,4 +263,4 @@ module Types end end -Types::MergeRequestType.prepend_if_ee('::EE::Types::MergeRequestType') +Types::MergeRequestType.prepend_mod_with('Types::MergeRequestType') diff --git a/app/graphql/types/merge_requests/assignee_type.rb b/app/graphql/types/merge_requests/assignee_type.rb new file mode 100644 index 00000000000..8448477370e --- /dev/null +++ b/app/graphql/types/merge_requests/assignee_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module MergeRequests + class AssigneeType < ::Types::UserType + include FindClosest + include ::Types::MergeRequests::InteractsWithMergeRequest + + graphql_name 'MergeRequestAssignee' + description 'A user assigned to a merge request.' + authorize :read_user + end + end +end diff --git a/app/graphql/types/merge_requests/interacts_with_merge_request.rb b/app/graphql/types/merge_requests/interacts_with_merge_request.rb new file mode 100644 index 00000000000..d685ac4d3c9 --- /dev/null +++ b/app/graphql/types/merge_requests/interacts_with_merge_request.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module MergeRequests + module InteractsWithMergeRequest + extend ActiveSupport::Concern + + included do + field :merge_request_interaction, + type: ::Types::UserMergeRequestInteractionType, + null: true, + extras: [:parent], + description: "Details of this user's interactions with the merge request." + end + + def merge_request_interaction(parent:) + merge_request = closest_parent(::Types::MergeRequestType, parent) + return unless merge_request + + Users::MergeRequestInteraction.new(user: object, merge_request: merge_request) + end + end + end +end diff --git a/app/graphql/types/merge_requests/reviewer_type.rb b/app/graphql/types/merge_requests/reviewer_type.rb index 09ced39844a..1ced821c839 100644 --- a/app/graphql/types/merge_requests/reviewer_type.rb +++ b/app/graphql/types/merge_requests/reviewer_type.rb @@ -4,23 +4,11 @@ module Types module MergeRequests class ReviewerType < ::Types::UserType include FindClosest + include ::Types::MergeRequests::InteractsWithMergeRequest graphql_name 'MergeRequestReviewer' - description 'A user from whom a merge request review has been requested.' + description 'A user assigned to a merge request as a reviewer.' authorize :read_user - - field :merge_request_interaction, - type: ::Types::UserMergeRequestInteractionType, - null: true, - extras: [:parent], - description: "Details of this user's interactions with the merge request." - - def merge_request_interaction(parent:) - merge_request = closest_parent(::Types::MergeRequestType, parent) - return unless merge_request - - Users::MergeRequestInteraction.new(user: object, merge_request: merge_request) - end end end end diff --git a/app/graphql/types/metadata/kas_type.rb b/app/graphql/types/metadata/kas_type.rb new file mode 100644 index 00000000000..8af4c23270b --- /dev/null +++ b/app/graphql/types/metadata/kas_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Metadata + class KasType < ::Types::BaseObject + graphql_name 'Kas' + + authorize :read_instance_metadata + + field :enabled, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates whether the Kubernetes Agent Server is enabled.' + field :version, GraphQL::STRING_TYPE, null: true, + description: 'KAS version.' + field :external_url, GraphQL::STRING_TYPE, null: true, + description: 'The URL used by the Agents to communicate with KAS.' + end + end +end diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb index 0c360d4f292..851c2a3f1e3 100644 --- a/app/graphql/types/metadata_type.rb +++ b/app/graphql/types/metadata_type.rb @@ -10,5 +10,7 @@ module Types description: 'Version.' field :revision, GraphQL::STRING_TYPE, null: false, description: 'Revision.' + field :kas, ::Types::Metadata::KasType, null: false, + description: 'Metadata about KAS.' end end diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb index 91a5109c748..eafede26c9e 100644 --- a/app/graphql/types/milestone_type.rb +++ b/app/graphql/types/milestone_type.rb @@ -57,11 +57,9 @@ module Types description: 'Milestone statistics.' def stats - return unless Feature.enabled?(:graphql_milestone_stats, milestone.project || milestone.group, default_enabled: true) - milestone end end end -Types::MilestoneType.prepend_if_ee('::EE::Types::MilestoneType') +Types::MilestoneType.prepend_mod_with('Types::MilestoneType') diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 5a9c7b32deb..54a06ed5342 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -16,6 +16,7 @@ module Types mount_mutation Mutations::AlertManagement::HttpIntegration::ResetToken mount_mutation Mutations::AlertManagement::HttpIntegration::Destroy mount_mutation Mutations::Security::CiConfiguration::ConfigureSast + mount_mutation Mutations::Security::CiConfiguration::ConfigureSecretDetection mount_mutation Mutations::AlertManagement::PrometheusIntegration::Create mount_mutation Mutations::AlertManagement::PrometheusIntegration::Update mount_mutation Mutations::AlertManagement::PrometheusIntegration::ResetToken @@ -51,7 +52,10 @@ module Types mount_mutation Mutations::MergeRequests::SetLocked mount_mutation Mutations::MergeRequests::SetMilestone mount_mutation Mutations::MergeRequests::SetSubscription - mount_mutation Mutations::MergeRequests::SetWip, calls_gitaly: true + mount_mutation Mutations::MergeRequests::SetWip, + calls_gitaly: true, + deprecated: { reason: 'Use mergeRequestSetDraft', milestone: '13.12' } + mount_mutation Mutations::MergeRequests::SetDraft, calls_gitaly: true mount_mutation Mutations::MergeRequests::SetAssignees mount_mutation Mutations::MergeRequests::ReviewerRereview mount_mutation Mutations::Metrics::Dashboard::Annotations::Create @@ -93,10 +97,12 @@ module Types mount_mutation Mutations::Ci::Pipeline::Destroy mount_mutation Mutations::Ci::Pipeline::Retry mount_mutation Mutations::Ci::CiCdSettingsUpdate + mount_mutation Mutations::Ci::Job::Play + mount_mutation Mutations::Ci::Job::Retry mount_mutation Mutations::Namespace::PackageSettings::Update mount_mutation Mutations::UserCallouts::Create end end ::Types::MutationType.prepend(::Types::DeprecatedMutations) -::Types::MutationType.prepend_if_ee('::EE::Types::MutationType') +::Types::MutationType.prepend_mod_with('Types::MutationType') diff --git a/app/graphql/types/namespace/package_settings_type.rb b/app/graphql/types/namespace/package_settings_type.rb index 0720a1cfb4b..af091515979 100644 --- a/app/graphql/types/namespace/package_settings_type.rb +++ b/app/graphql/types/namespace/package_settings_type.rb @@ -10,5 +10,7 @@ module Types field :maven_duplicates_allowed, GraphQL::BOOLEAN_TYPE, null: false, description: 'Indicates whether duplicate Maven packages are allowed for this namespace.' field :maven_duplicate_exception_regex, Types::UntrustedRegexp, null: true, description: 'When maven_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.' + field :generic_duplicates_allowed, GraphQL::BOOLEAN_TYPE, null: false, description: 'Indicates whether duplicate generic packages are allowed for this namespace.' + field :generic_duplicate_exception_regex, Types::UntrustedRegexp, null: true, description: 'When generic_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.' end end diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index da983399a11..96eff8a46b0 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -48,4 +48,4 @@ module Types end end -Types::NamespaceType.prepend_if_ee('EE::Types::NamespaceType') +Types::NamespaceType.prepend_mod_with('Types::NamespaceType') diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb index f8626d249a1..a82a76f9c87 100644 --- a/app/graphql/types/notes/noteable_type.rb +++ b/app/graphql/types/notes/noteable_type.rb @@ -28,4 +28,4 @@ module Types end end -Types::Notes::NoteableType.prepend_if_ee('::EE::Types::Notes::NoteableType') +Types::Notes::NoteableType.prepend_mod_with('Types::Notes::NoteableType') diff --git a/app/graphql/types/notes/position_type_enum.rb b/app/graphql/types/notes/position_type_enum.rb index 9939f6511ce..18934636670 100644 --- a/app/graphql/types/notes/position_type_enum.rb +++ b/app/graphql/types/notes/position_type_enum.rb @@ -6,8 +6,8 @@ module Types graphql_name 'DiffPositionType' description 'Type of file the position refers to' - value 'text', description: "A text file" - value 'image', description: "An image" + value 'text', description: "A text file." + value 'image', description: "An image." end end end diff --git a/app/graphql/types/packages/maven/metadatum_type.rb b/app/graphql/types/packages/maven/metadatum_type.rb new file mode 100644 index 00000000000..bdb250ef96b --- /dev/null +++ b/app/graphql/types/packages/maven/metadatum_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Packages + module Maven + class MetadatumType < BaseObject + graphql_name 'MavenMetadata' + description 'Maven metadata' + + authorize :read_package + + field :id, ::Types::GlobalIDType[::Packages::Maven::Metadatum], null: false, description: 'ID of the metadatum.' + field :created_at, Types::TimeType, null: false, description: 'Date of creation.' + field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.' + field :path, GraphQL::STRING_TYPE, null: false, description: 'Path of the Maven package.' + field :app_group, GraphQL::STRING_TYPE, null: false, description: 'App group of the Maven package.' + field :app_version, GraphQL::STRING_TYPE, null: true, description: 'App version of the Maven package.' + field :app_name, GraphQL::STRING_TYPE, null: false, description: 'App name of the Maven package.' + end + end + end +end diff --git a/app/graphql/types/packages/metadata_type.rb b/app/graphql/types/packages/metadata_type.rb index 4ab6707df88..94880cb9b22 100644 --- a/app/graphql/types/packages/metadata_type.rb +++ b/app/graphql/types/packages/metadata_type.rb @@ -6,7 +6,7 @@ module Types graphql_name 'PackageMetadata' description 'Represents metadata associated with a Package' - possible_types ::Types::Packages::Composer::MetadatumType, ::Types::Packages::Conan::MetadatumType + possible_types ::Types::Packages::Composer::MetadatumType, ::Types::Packages::Conan::MetadatumType, ::Types::Packages::Maven::MetadatumType, ::Types::Packages::Nuget::MetadatumType def self.resolve_type(object, context) case object @@ -14,6 +14,10 @@ module Types ::Types::Packages::Composer::MetadatumType when ::Packages::Conan::Metadatum ::Types::Packages::Conan::MetadatumType + when ::Packages::Maven::Metadatum + ::Types::Packages::Maven::MetadatumType + when ::Packages::Nuget::Metadatum + ::Types::Packages::Nuget::MetadatumType else # NOTE: This method must be kept in sync with `PackageWithoutVersionsType#metadata`, # which must never produce data that this discriminator cannot handle. diff --git a/app/graphql/types/packages/nuget/metadatum_type.rb b/app/graphql/types/packages/nuget/metadatum_type.rb new file mode 100644 index 00000000000..63fae2fb197 --- /dev/null +++ b/app/graphql/types/packages/nuget/metadatum_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Packages + module Nuget + class MetadatumType < BaseObject + graphql_name 'NugetMetadata' + description 'Nuget metadata' + + authorize :read_package + + field :id, ::Types::GlobalIDType[::Packages::Nuget::Metadatum], null: false, description: 'ID of the metadatum.' + field :license_url, GraphQL::STRING_TYPE, null: false, description: 'License URL of the Nuget package.' + field :project_url, GraphQL::STRING_TYPE, null: false, description: 'Project URL of the Nuget package.' + field :icon_url, GraphQL::STRING_TYPE, null: false, description: 'Icon URL of the Nuget package.' + end + end + end +end diff --git a/app/graphql/types/packages/package_group_sort_enum.rb b/app/graphql/types/packages/package_group_sort_enum.rb new file mode 100644 index 00000000000..70fb27ec0db --- /dev/null +++ b/app/graphql/types/packages/package_group_sort_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Packages + class PackageGroupSortEnum < PackageSortEnum + graphql_name 'PackageGroupSort' + description 'Values for sorting group packages' + + # The following enums are not available till we enable the new Arel node: + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/58657#note_552632305 + # value 'PROJECT_PATH_DESC', 'Project by descending order.', value: :project_path_desc + # value 'PROJECT_PATH_ASC', 'Project by ascending order.', value: :project_path_asc + end + end +end diff --git a/app/graphql/types/packages/package_sort_enum.rb b/app/graphql/types/packages/package_sort_enum.rb new file mode 100644 index 00000000000..ee14cf7a9e6 --- /dev/null +++ b/app/graphql/types/packages/package_sort_enum.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Packages + class PackageSortEnum < BaseEnum + graphql_name 'PackageSort' + description 'Values for sorting package' + + value 'CREATED_DESC', 'Ordered by created_at in descending order.', value: :created_desc + value 'CREATED_ASC', 'Ordered by created_at in ascending order.', value: :created_asc + value 'NAME_DESC', 'Ordered by name in descending order.', value: :name_desc + value 'NAME_ASC', 'Ordered by name in ascending order.', value: :name_asc + value 'VERSION_DESC', 'Ordered by version in descending order.', value: :version_desc + value 'VERSION_ASC', 'Ordered by version in ascending order.', value: :version_asc + value 'TYPE_DESC', 'Ordered by type in descending order.', value: :type_desc + value 'TYPE_ASC', 'Ordered by type in ascending order.', value: :type_asc + end + end +end diff --git a/app/graphql/types/packages/package_status_enum.rb b/app/graphql/types/packages/package_status_enum.rb new file mode 100644 index 00000000000..2e6ea5d0a50 --- /dev/null +++ b/app/graphql/types/packages/package_status_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Packages + class PackageStatusEnum < BaseEnum + graphql_name 'PackageStatus' + + ::Packages::Package.statuses.keys.each do |status| + value status.to_s.upcase, description: "Packages with a #{status} status", value: status.to_s + end + end + end +end diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb index a263ca1577a..b349b655fa5 100644 --- a/app/graphql/types/packages/package_type.rb +++ b/app/graphql/types/packages/package_type.rb @@ -25,6 +25,7 @@ module Types field :versions, ::Types::Packages::PackageType.connection_type, null: true, description: 'The other versions of the package.', deprecated: { reason: 'This field is now only returned in the PackageDetailsType', milestone: '13.11' } + field :status, Types::Packages::PackageStatusEnum, null: false, description: 'Package status.' def project Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find @@ -44,6 +45,10 @@ module Types object.composer_metadatum when 'conan' object.conan_metadatum + when 'maven' + object.maven_metadatum + when 'nuget' + object.nuget_metadatum else nil end diff --git a/app/graphql/types/packages/package_type_enum.rb b/app/graphql/types/packages/package_type_enum.rb index e2b5cf3163e..17145d8e000 100644 --- a/app/graphql/types/packages/package_type_enum.rb +++ b/app/graphql/types/packages/package_type_enum.rb @@ -5,12 +5,13 @@ module Types class PackageTypeEnum < BaseEnum PACKAGE_TYPE_NAMES = { pypi: 'PyPI', - npm: 'npm' + npm: 'npm', + terraform_module: 'Terraform Module' }.freeze ::Packages::Package.package_types.keys.each do |package_type| type_name = PACKAGE_TYPE_NAMES.fetch(package_type.to_sym, package_type.capitalize) - value package_type.to_s.upcase, "Packages from the #{type_name} package manager", value: package_type.to_s + value package_type.to_s.upcase, description: "Packages from the #{type_name} package manager", value: package_type.to_s end end end diff --git a/app/graphql/types/permission_types/ci/job.rb b/app/graphql/types/permission_types/ci/job.rb new file mode 100644 index 00000000000..c9a85317e67 --- /dev/null +++ b/app/graphql/types/permission_types/ci/job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + module Ci + class Job < BasePermissionType + graphql_name 'JobPermissions' + + abilities :read_job_artifacts, :read_build + ability_field :update_build, calls_gitaly: true + end + end + end +end diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb index 5747e63d195..f6a5563d367 100644 --- a/app/graphql/types/permission_types/project.rb +++ b/app/graphql/types/permission_types/project.rb @@ -27,3 +27,5 @@ module Types end end end + +::Types::PermissionTypes::Project.prepend_mod_with('Types::PermissionTypes::Project') diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 21534f40499..a2852588e89 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -30,8 +30,12 @@ module Types markdown_field :description_html, null: true field :tag_list, GraphQL::STRING_TYPE, null: true, + deprecated: { reason: 'Use `topics`', milestone: '13.12' }, description: 'List of project topics (not Git tags).' + field :topics, [GraphQL::STRING_TYPE], null: true, + description: 'List of project topics.' + field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true, description: 'URL to connect to the project via SSH.' field :http_url_to_repo, GraphQL::STRING_TYPE, null: true, @@ -180,14 +184,15 @@ module Types resolver: Resolvers::IssuesResolver.single field :packages, - description: 'Packages of the project.', - resolver: Resolvers::ProjectPackagesResolver + description: 'Packages of the project.', + resolver: Resolvers::ProjectPackagesResolver field :jobs, - Types::Ci::JobType.connection_type, - null: true, - description: 'Jobs of a project. This field can only be resolved for one project in any single request.', - resolver: Resolvers::ProjectJobsResolver + type: Types::Ci::JobType.connection_type, + null: true, + authorize: :read_commit_status, + description: 'Jobs of a project. This field can only be resolved for one project in any single request.', + resolver: Resolvers::ProjectJobsResolver field :pipelines, null: true, @@ -337,6 +342,10 @@ module Types description: 'Pipeline analytics.', resolver: Resolvers::ProjectPipelineStatisticsResolver + field :ci_template, Types::Ci::TemplateType, null: true, + description: 'Find a single CI/CD template by name.', + resolver: Resolvers::Ci::TemplateResolver + def label(title:) BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| LabelsFinder @@ -386,4 +395,4 @@ module Types end end -Types::ProjectType.prepend_if_ee('::EE::Types::ProjectType') +Types::ProjectType.prepend_mod_with('Types::ProjectType') diff --git a/app/graphql/types/projects/service_type_enum.rb b/app/graphql/types/projects/service_type_enum.rb index fcb36fc233d..0a57cd48df4 100644 --- a/app/graphql/types/projects/service_type_enum.rb +++ b/app/graphql/types/projects/service_type_enum.rb @@ -5,7 +5,7 @@ module Types class ServiceTypeEnum < BaseEnum graphql_name 'ServiceType' - ::Service.available_services_types(include_dev: false).each do |service_type| + ::Integration.available_services_types(include_dev: false).each do |service_type| value service_type.underscore.upcase, value: service_type, description: "#{service_type} type" end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 8af0db644dd..8b7b9f0107b 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -79,8 +79,14 @@ module Types field :issue, Types::IssueType, null: true, - description: 'Find an Issue.' do - argument :id, ::Types::GlobalIDType[::Issue], required: true, description: 'The global ID of the Issue.' + description: 'Find an issue.' do + argument :id, ::Types::GlobalIDType[::Issue], required: true, description: 'The global ID of the issue.' + end + + field :merge_request, Types::MergeRequestType, + null: true, + description: 'Find a merge request.' do + argument :id, ::Types::GlobalIDType[::MergeRequest], required: true, description: 'The global ID of the merge request.' end field :instance_statistics_measurements, @@ -106,6 +112,19 @@ module Types field :runner_platforms, resolver: Resolvers::Ci::RunnerPlatformsResolver field :runner_setup, resolver: Resolvers::Ci::RunnerSetupResolver + field :runner, Types::Ci::RunnerType, + null: true, + resolver: Resolvers::Ci::RunnerResolver, + extras: [:lookahead], + description: "Find a runner.", + feature_flag: :runner_graphql_query + + field :runners, Types::Ci::RunnerType.connection_type, + null: true, + resolver: Resolvers::Ci::RunnersResolver, + description: "Find runners visible to the current user.", + feature_flag: :runner_graphql_query + field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1 def design_management @@ -119,6 +138,13 @@ module Types GitlabSchema.find_by_gid(id) end + def merge_request(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[::MergeRequest].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + def milestone(id:) # TODO: remove this line when the compatibility layer is removed # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 @@ -147,4 +173,4 @@ module Types end end -Types::QueryType.prepend_if_ee('EE::Types::QueryType') +Types::QueryType.prepend_mod_with('Types::QueryType') diff --git a/app/graphql/types/release_assets_type.rb b/app/graphql/types/release_assets_type.rb index 79c132358e0..d847d9842d5 100644 --- a/app/graphql/types/release_assets_type.rb +++ b/app/graphql/types/release_assets_type.rb @@ -13,7 +13,7 @@ module Types field :count, GraphQL::INT_TYPE, null: true, method: :assets_count, description: 'Number of assets of the release.' - field :links, Types::ReleaseAssetLinkType.connection_type, null: true, + field :links, Types::ReleaseAssetLinkType.connection_type, null: true, method: :sorted_links, description: 'Asset links of the release.' field :sources, Types::ReleaseSourceType.connection_type, null: true, description: 'Sources of the release.' diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb index 912fc5f643a..8ed97d7e663 100644 --- a/app/graphql/types/repository/blob_type.rb +++ b/app/graphql/types/repository/blob_type.rb @@ -32,6 +32,66 @@ module Types field :web_path, GraphQL::STRING_TYPE, null: true, description: 'Web path of the blob.' + field :ide_edit_path, GraphQL::STRING_TYPE, null: true, + description: 'Web path to edit this blob in the Web IDE.' + + field :fork_and_edit_path, GraphQL::STRING_TYPE, null: true, + description: 'Web path to edit this blob using a forked project.' + + field :ide_fork_and_edit_path, GraphQL::STRING_TYPE, null: true, + description: 'Web path to edit this blob in the Web IDE using a forked project.' + + field :size, GraphQL::INT_TYPE, null: true, + description: 'Size (in bytes) of the blob.' + + field :raw_size, GraphQL::INT_TYPE, null: true, + description: 'Size (in bytes) of the blob, or the blob target if stored externally.' + + field :raw_blob, GraphQL::STRING_TYPE, null: true, method: :data, + description: 'The raw content of the blob.' + + field :raw_text_blob, GraphQL::STRING_TYPE, null: true, method: :text_only_data, + description: 'The raw content of the blob, if the blob is text data.' + + field :stored_externally, GraphQL::BOOLEAN_TYPE, null: true, method: :stored_externally?, + description: "Whether the blob's content is stored externally (for instance, in LFS)." + + field :edit_blob_path, GraphQL::STRING_TYPE, null: true, + description: 'Web path to edit the blob in the old-style editor.' + + field :raw_path, GraphQL::STRING_TYPE, null: true, + description: 'Web path to download the raw blob.' + + field :external_storage_url, GraphQL::STRING_TYPE, null: true, + description: 'Web path to download the raw blob via external storage, if enabled.' + + field :replace_path, GraphQL::STRING_TYPE, null: true, + description: 'Web path to replace the blob content.' + + field :file_type, GraphQL::STRING_TYPE, null: true, + description: 'The expected format of the blob based on the extension.' + + field :simple_viewer, type: Types::BlobViewerType, + description: 'Blob content simple viewer.', + null: false + + field :rich_viewer, type: Types::BlobViewerType, + description: 'Blob content rich viewer.', + null: true + + field :plain_data, GraphQL::STRING_TYPE, + description: 'Blob plain highlighted data.', + null: true, + calls_gitaly: true + + field :can_modify_blob, GraphQL::BOOLEAN_TYPE, null: true, method: :can_modify_blob?, + calls_gitaly: true, + description: 'Whether the current user can modify the blob.' + + def raw_text_blob + object.data unless object.binary? + end + def lfs_oid Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(object.repository, object.id).find end diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb index 963a4296c4f..9d896888fa7 100644 --- a/app/graphql/types/repository_type.rb +++ b/app/graphql/types/repository_type.rb @@ -19,5 +19,9 @@ module Types field :branch_names, [GraphQL::STRING_TYPE], null: true, calls_gitaly: true, complexity: 170, description: 'Names of branches available in this repository that match the search pattern.', resolver: Resolvers::RepositoryBranchNamesResolver + field :disk_path, GraphQL::STRING_TYPE, + description: 'Shows a disk path of the repository.', + null: true, + authorize: :read_storage_disk_path end end diff --git a/app/graphql/types/snippets/blob_viewer_type.rb b/app/graphql/types/snippets/blob_viewer_type.rb index 9e77457c843..8b73234bbd9 100644 --- a/app/graphql/types/snippets/blob_viewer_type.rb +++ b/app/graphql/types/snippets/blob_viewer_type.rb @@ -2,48 +2,10 @@ module Types module Snippets - class BlobViewerType < BaseObject # rubocop:disable Graphql/AuthorizeTypes + # Kept to avoid changing the type of existing fields. New fields should use + # ::Types::BlobViewerType directly + class BlobViewerType < ::Types::BlobViewerType # rubocop:disable Graphql/AuthorizeTypes graphql_name 'SnippetBlobViewer' - description 'Represents how the blob content should be displayed' - - field :type, Types::BlobViewers::TypeEnum, - description: 'Type of blob viewer.', - null: false - - field :load_async, GraphQL::BOOLEAN_TYPE, - description: 'Shows whether the blob content is loaded asynchronously.', - null: false - - field :collapsed, GraphQL::BOOLEAN_TYPE, - description: 'Shows whether the blob should be displayed collapsed.', - method: :collapsed?, - null: false - - field :too_large, GraphQL::BOOLEAN_TYPE, - description: 'Shows whether the blob too large to be displayed.', - method: :too_large?, - null: false - - field :render_error, GraphQL::STRING_TYPE, - description: 'Error rendering the blob content.', - null: true - - field :file_type, GraphQL::STRING_TYPE, - description: 'Content file type.', - method: :partial_name, - null: false - - field :loading_partial_name, GraphQL::STRING_TYPE, - description: 'Loading partial name.', - null: false - - def collapsed - !!object&.collapsed? - end - - def too_large - !!object&.too_large? - end end end end diff --git a/app/graphql/types/snippets/type_enum.rb b/app/graphql/types/snippets/type_enum.rb index 243f05359db..0ddd73d278d 100644 --- a/app/graphql/types/snippets/type_enum.rb +++ b/app/graphql/types/snippets/type_enum.rb @@ -3,8 +3,8 @@ module Types module Snippets class TypeEnum < BaseEnum - value 'personal', value: 'personal' - value 'project', value: 'project' + value 'personal', description: 'Snippet created independent of any project.', value: 'personal' + value 'project', description: 'Snippet related to a specific project.', value: 'project' end end end diff --git a/app/graphql/types/subscription_type.rb b/app/graphql/types/subscription_type.rb new file mode 100644 index 00000000000..5356a998f0d --- /dev/null +++ b/app/graphql/types/subscription_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + class SubscriptionType < ::Types::BaseObject + graphql_name 'Subscription' + + field :issuable_assignees_updated, subscription: Subscriptions::IssuableUpdated, null: true, + description: 'Triggered when the assignees of an issuable are updated.' + end +end diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb index 465e3c492bc..99a619f1b1d 100644 --- a/app/graphql/types/timelog_type.rb +++ b/app/graphql/types/timelog_type.rb @@ -26,6 +26,11 @@ module Types null: true, description: 'The issue that logged time was added to.' + field :merge_request, + Types::MergeRequestType, + null: true, + description: 'The merge request that logged time was added to.' + field :note, Types::Notes::NoteType, null: true, @@ -38,5 +43,9 @@ module Types def issue Gitlab::Graphql::Loaders::BatchModelLoader.new(Issue, object.issue_id).find end + + def spent_at + object.spent_at || object.created_at + end end end diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb index ebf65e99936..ce61bc8a926 100644 --- a/app/graphql/types/todo_target_enum.rb +++ b/app/graphql/types/todo_target_enum.rb @@ -10,4 +10,4 @@ module Types end end -Types::TodoTargetEnum.prepend_if_ee('::EE::Types::TodoTargetEnum') +Types::TodoTargetEnum.prepend_mod_with('Types::TodoTargetEnum') diff --git a/app/graphql/types/tree/type_enum.rb b/app/graphql/types/tree/type_enum.rb index 6560d91e9e5..7acb83a2a8a 100644 --- a/app/graphql/types/tree/type_enum.rb +++ b/app/graphql/types/tree/type_enum.rb @@ -6,9 +6,9 @@ module Types graphql_name 'EntryType' description 'Type of a tree entry' - value 'tree', value: :tree - value 'blob', value: :blob - value 'commit', value: :commit + value 'tree', description: 'Directory tree type.', value: :tree + value 'blob', description: 'File tree type.', value: :blob + value 'commit', description: 'Commit tree type.', value: :commit end end end diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb new file mode 100644 index 00000000000..e5abc033155 --- /dev/null +++ b/app/graphql/types/user_interface.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Types + module UserInterface + include Types::BaseInterface + + graphql_name 'User' + description 'Representation of a GitLab user.' + + field :user_permissions, + type: Types::PermissionTypes::User, + description: 'Permissions for the current user on the resource.', + null: false, + method: :itself + + field :id, + type: GraphQL::ID_TYPE, + null: false, + description: 'ID of the user.' + field :bot, + type: GraphQL::BOOLEAN_TYPE, + null: false, + description: 'Indicates if the user is a bot.', + method: :bot? + field :username, + type: GraphQL::STRING_TYPE, + null: false, + description: 'Username of the user. Unique within this instance of GitLab.' + field :name, + type: GraphQL::STRING_TYPE, + null: false, + description: 'Human-readable name of the user.' + field :state, + type: Types::UserStateEnum, + null: false, + description: 'State of the user.' + field :email, + type: GraphQL::STRING_TYPE, + null: true, + description: 'User email.', method: :public_email, + deprecated: { reason: :renamed, replacement: 'User.publicEmail', milestone: '13.7' } + field :public_email, + type: GraphQL::STRING_TYPE, + null: true, + description: "User's public email." + field :avatar_url, + type: GraphQL::STRING_TYPE, + null: true, + description: "URL of the user's avatar." + field :web_url, + type: GraphQL::STRING_TYPE, + null: false, + description: 'Web URL of the user.' + field :web_path, + type: GraphQL::STRING_TYPE, + null: false, + description: 'Web path of the user.' + field :todos, + resolver: Resolvers::TodoResolver, + description: 'To-do items of the user.' + field :group_memberships, + type: Types::GroupMemberType.connection_type, + null: true, + description: 'Group memberships of the user.' + field :group_count, + resolver: Resolvers::Users::GroupCountResolver, + description: 'Group count for the user.' + field :status, + type: Types::UserStatusType, + null: true, + description: 'User status.' + field :location, + type: ::GraphQL::STRING_TYPE, + null: true, + description: 'The location of the user.' + field :project_memberships, + type: Types::ProjectMemberType.connection_type, + null: true, + description: 'Project memberships of the user.' + field :starred_projects, + description: 'Projects starred by the user.', + resolver: Resolvers::UserStarredProjectsResolver + + # Merge request field: MRs can be authored, assigned, or assigned-for-review: + field :authored_merge_requests, + resolver: Resolvers::AuthoredMergeRequestsResolver, + description: 'Merge requests authored by the user.' + field :assigned_merge_requests, + resolver: Resolvers::AssignedMergeRequestsResolver, + description: 'Merge requests assigned to the user.' + field :review_requested_merge_requests, + resolver: Resolvers::ReviewRequestedMergeRequestsResolver, + description: 'Merge requests assigned to the user for review.' + + field :snippets, + description: 'Snippets authored by the user.', + resolver: Resolvers::Users::SnippetsResolver + field :callouts, + Types::UserCalloutType.connection_type, + null: true, + description: 'User callouts that belong to the user.' + + definition_methods do + def resolve_type(object, context) + # in the absense of other information, we cannot tell - just default to + # the core user type. + ::Types::UserType + end + end + end +end diff --git a/app/graphql/types/user_merge_request_interaction_type.rb b/app/graphql/types/user_merge_request_interaction_type.rb index 5ff0d79f13e..b9ff489e0d6 100644 --- a/app/graphql/types/user_merge_request_interaction_type.rb +++ b/app/graphql/types/user_merge_request_interaction_type.rb @@ -44,4 +44,4 @@ module Types end end -::Types::UserMergeRequestInteractionType.prepend_if_ee('EE::Types::UserMergeRequestInteractionType') +::Types::UserMergeRequestInteractionType.prepend_mod_with('Types::UserMergeRequestInteractionType') diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 3d7db80ae11..a6f5b7e7456 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -1,102 +1,13 @@ # frozen_string_literal: true module Types - class UserType < BaseObject - graphql_name 'User' - description 'Representation of a GitLab user.' + class UserType < ::Types::BaseObject + graphql_name 'UserCore' + description 'Core represention of a GitLab user.' + implements ::Types::UserInterface authorize :read_user present_using UserPresenter - - expose_permissions Types::PermissionTypes::User - - field :id, - type: GraphQL::ID_TYPE, - null: false, - description: 'ID of the user.' - field :bot, - type: GraphQL::BOOLEAN_TYPE, - null: false, - description: 'Indicates if the user is a bot.', - method: :bot? - field :username, - type: GraphQL::STRING_TYPE, - null: false, - description: 'Username of the user. Unique within this instance of GitLab.' - field :name, - type: GraphQL::STRING_TYPE, - null: false, - description: 'Human-readable name of the user.' - field :state, - type: Types::UserStateEnum, - null: false, - description: 'State of the user.' - field :email, - type: GraphQL::STRING_TYPE, - null: true, - description: 'User email.', method: :public_email, - deprecated: { reason: :renamed, replacement: 'User.publicEmail', milestone: '13.7' } - field :public_email, - type: GraphQL::STRING_TYPE, - null: true, - description: "User's public email." - field :avatar_url, - type: GraphQL::STRING_TYPE, - null: true, - description: "URL of the user's avatar." - field :web_url, - type: GraphQL::STRING_TYPE, - null: false, - description: 'Web URL of the user.' - field :web_path, - type: GraphQL::STRING_TYPE, - null: false, - description: 'Web path of the user.' - field :todos, - resolver: Resolvers::TodoResolver, - description: 'To-do items of the user.' - field :group_memberships, - type: Types::GroupMemberType.connection_type, - null: true, - description: 'Group memberships of the user.' - field :group_count, - resolver: Resolvers::Users::GroupCountResolver, - description: 'Group count for the user.', - feature_flag: :user_group_counts - field :status, - type: Types::UserStatusType, - null: true, - description: 'User status.' - field :location, - type: ::GraphQL::STRING_TYPE, - null: true, - description: 'The location of the user.' - field :project_memberships, - type: Types::ProjectMemberType.connection_type, - null: true, - description: 'Project memberships of the user.' - field :starred_projects, - description: 'Projects starred by the user.', - resolver: Resolvers::UserStarredProjectsResolver - - # Merge request field: MRs can be authored, assigned, or assigned-for-review: - field :authored_merge_requests, - resolver: Resolvers::AuthoredMergeRequestsResolver, - description: 'Merge requests authored by the user.' - field :assigned_merge_requests, - resolver: Resolvers::AssignedMergeRequestsResolver, - description: 'Merge requests assigned to the user.' - field :review_requested_merge_requests, - resolver: Resolvers::ReviewRequestedMergeRequestsResolver, - description: 'Merge requests assigned to the user for review.' - - field :snippets, - description: 'Snippets authored by the user.', - resolver: Resolvers::Users::SnippetsResolver - field :callouts, - Types::UserCalloutType.connection_type, - null: true, - description: 'User callouts that belong to the user.' end end diff --git a/app/helpers/analytics/navbar_helper.rb b/app/helpers/analytics/navbar_helper.rb index 33a5028cdf1..091571ff15a 100644 --- a/app/helpers/analytics/navbar_helper.rb +++ b/app/helpers/analytics/navbar_helper.rb @@ -13,14 +13,6 @@ module Analytics end end - def project_analytics_navbar_links(project, current_user) - [ - cycle_analytics_navbar_link(project, current_user), - repository_analytics_navbar_link(project, current_user), - ci_cd_analytics_navbar_link(project, current_user) - ].compact - end - def group_analytics_navbar_links(group, current_user) [] end @@ -30,40 +22,7 @@ module Analytics def navbar_sub_item(args) NavbarSubItem.new(**args) end - - def cycle_analytics_navbar_link(project, current_user) - return unless project_nav_tab?(:cycle_analytics) - - navbar_sub_item( - title: _('Value Stream'), - path: 'cycle_analytics#show', - link: project_cycle_analytics_path(project), - link_to_options: { class: 'shortcuts-project-cycle-analytics' } - ) - end - - def repository_analytics_navbar_link(project, current_user) - return if project.empty_repo? - - navbar_sub_item( - title: _('Repository'), - path: 'graphs#charts', - link: charts_project_graph_path(project, current_ref), - link_to_options: { class: 'shortcuts-repository-charts' } - ) - end - - def ci_cd_analytics_navbar_link(project, current_user) - return unless project_nav_tab?(:pipelines) - return unless project.feature_available?(:builds, current_user) || !project.empty_repo? - - navbar_sub_item( - title: _('CI/CD'), - path: 'pipelines#charts', - link: charts_project_pipelines_path(project) - ) - end end end -Analytics::NavbarHelper.prepend_if_ee('EE::Analytics::NavbarHelper') +Analytics::NavbarHelper.prepend_mod_with('Analytics::NavbarHelper') diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 65feea4f6e0..60e37c96f61 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -83,5 +83,4 @@ module AppearancesHelper end end -AppearancesHelper.prepend_if_ee('EE::AppearancesHelper') -AppearancesHelper.prepend_if_jh('JH::AppearancesHelper') +AppearancesHelper.prepend_mod diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a2ef2f1207c..2e15b3f22c2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -72,7 +72,7 @@ module ApplicationHelper else 'Never' end - rescue + rescue StandardError 'Never' end @@ -382,15 +382,26 @@ module ApplicationHelper def autocomplete_data_sources(object, noteable_type) return {} unless object && noteable_type - { - members: members_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), - issues: issues_project_autocomplete_sources_path(object), - mergeRequests: merge_requests_project_autocomplete_sources_path(object), - labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), - milestones: milestones_project_autocomplete_sources_path(object), - commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), - snippets: snippets_project_autocomplete_sources_path(object) - } + if object.is_a?(Group) + { + members: members_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), + issues: issues_group_autocomplete_sources_path(object), + mergeRequests: merge_requests_group_autocomplete_sources_path(object), + labels: labels_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), + milestones: milestones_group_autocomplete_sources_path(object), + commands: commands_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]) + } + else + { + members: members_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), + issues: issues_project_autocomplete_sources_path(object), + mergeRequests: merge_requests_project_autocomplete_sources_path(object), + labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), + milestones: milestones_project_autocomplete_sources_path(object), + commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), + snippets: snippets_project_autocomplete_sources_path(object) + } + end end def asset_to_string(name) @@ -409,5 +420,4 @@ module ApplicationHelper end end -ApplicationHelper.prepend_if_ee('EE::ApplicationHelper') -ApplicationHelper.prepend_if_jh('JH::ApplicationHelper') +ApplicationHelper.prepend_mod diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 504ebb5606e..0e3dff27da9 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -233,6 +233,7 @@ module ApplicationSettingsHelper :external_pipeline_validation_service_token, :external_pipeline_validation_service_url, :first_day_of_week, + :floc_enabled, :force_pages_access_control, :gitaly_timeout_default, :gitaly_timeout_medium, @@ -302,6 +303,7 @@ module ApplicationSettingsHelper :sourcegraph_public_only, :spam_check_endpoint_enabled, :spam_check_endpoint_url, + :spam_check_api_key, :terminal_max_session_time, :terms, :throttle_authenticated_api_enabled, @@ -310,9 +312,15 @@ module ApplicationSettingsHelper :throttle_authenticated_web_enabled, :throttle_authenticated_web_period_in_seconds, :throttle_authenticated_web_requests_per_period, + :throttle_authenticated_packages_api_enabled, + :throttle_authenticated_packages_api_period_in_seconds, + :throttle_authenticated_packages_api_requests_per_period, :throttle_unauthenticated_enabled, :throttle_unauthenticated_period_in_seconds, :throttle_unauthenticated_requests_per_period, + :throttle_unauthenticated_packages_api_enabled, + :throttle_unauthenticated_packages_api_period_in_seconds, + :throttle_unauthenticated_packages_api_requests_per_period, :throttle_protected_paths_enabled, :throttle_protected_paths_period_in_seconds, :throttle_protected_paths_requests_per_period, @@ -358,7 +366,8 @@ module ApplicationSettingsHelper :rate_limiting_response_text, :container_registry_expiration_policies_worker_capacity, :container_registry_cleanup_tags_service_max_list_size, - :keep_latest_artifact + :keep_latest_artifact, + :whats_new_variant ] end @@ -387,7 +396,7 @@ module ApplicationSettingsHelper end def integration_expanded?(substring) - @application_setting.errors.any? { |k| k.to_s.start_with?(substring) } + @application_setting.errors.messages.any? { |k, _| k.to_s.start_with?(substring) } end def instance_clusters_enabled? @@ -429,8 +438,8 @@ module ApplicationSettingsHelper end end -ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper') +ApplicationSettingsHelper.prepend_mod_with('ApplicationSettingsHelper') # The methods in `EE::ApplicationSettingsHelper` should be available as both # instance and class methods. -ApplicationSettingsHelper.extend_if_ee('EE::ApplicationSettingsHelper') +ApplicationSettingsHelper.extend_mod_with('ApplicationSettingsHelper') diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index cacf9c7ad0b..a0c3a6f2f52 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -16,7 +16,7 @@ module AuthHelper twitter ).freeze LDAP_PROVIDER = /\Aldap/.freeze - TRIAL_REGISTRATION_PROVIDERS = %w(google_oauth2 github).freeze + POPULAR_PROVIDERS = %w(google_oauth2 github).freeze def ldap_enabled? Gitlab::Auth::Ldap::Config.enabled? @@ -116,19 +116,12 @@ module AuthHelper providers = button_based_providers.map(&:to_s) - disabled_providers providers.sort_by do |provider| - case provider - when 'google_oauth2' - 0 - when 'github' - 1 - else - 2 - end + POPULAR_PROVIDERS.index(provider) || POPULAR_PROVIDERS.length end end - def trial_enabled_button_based_providers - enabled_button_based_providers & TRIAL_REGISTRATION_PROVIDERS + def popular_enabled_button_based_providers + enabled_button_based_providers & POPULAR_PROVIDERS end def button_based_providers_enabled? @@ -176,11 +169,23 @@ module AuthHelper !current_user end + def auth_app_owner_text(owner) + return unless owner + + if owner.is_a?(Group) + group_link = link_to(owner.name, group_path(owner)) + _("This application was created for group %{group_link}.").html_safe % { group_link: group_link } + else + user_link = link_to(owner.name, user_path(owner)) + _("This application was created by %{user_link}.").html_safe % { user_link: user_link } + end + end + extend self end -AuthHelper.prepend_if_ee('EE::AuthHelper') +AuthHelper.prepend_mod_with('AuthHelper') # The methods added in EE should be available as both class and instance # methods, just like the methods provided by `AuthHelper` itself. -AuthHelper.extend_if_ee('EE::AuthHelper') +AuthHelper.extend_mod_with('AuthHelper') diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 09f91f350bd..4cfa1528d9b 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -98,6 +98,14 @@ module AvatarsHelper end end + def avatar_without_link(resource, options = {}) + if resource.is_a?(User) + user_avatar_without_link(options.merge(user: resource)) + elsif resource.is_a?(Group) + group_icon(resource, options.merge(class: 'avatar')) + end + end + private def avatar_icon_by_user_email_or_gravatar(email, size, scale, only_path:) @@ -136,11 +144,12 @@ module AvatarsHelper def source_identicon(source, options = {}) bg_key = (source.id % 7) + 1 + size_class = "s#{options[:size]}" if options[:size] options[:class] = - [*options[:class], "identicon bg#{bg_key}"].join(' ') + [*options[:class], "identicon bg#{bg_key}", size_class].compact.join(' ') - content_tag(:div, class: options[:class].strip) do + content_tag(:span, class: options[:class].strip) do source.name[0, 1].upcase end end diff --git a/app/helpers/award_emoji_helper.rb b/app/helpers/award_emoji_helper.rb index af9ab93d459..196415bb363 100644 --- a/app/helpers/award_emoji_helper.rb +++ b/app/helpers/award_emoji_helper.rb @@ -17,4 +17,4 @@ module AwardEmojiHelper end end -AwardEmojiHelper.prepend_if_ee('EE::AwardEmojiHelper') +AwardEmojiHelper.prepend_mod_with('AwardEmojiHelper') diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 3144686bba9..dfd6de3f1d5 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -206,10 +206,6 @@ module BlobHelper @gitlab_ci_ymls ||= TemplateFinder.all_template_names(project, :gitlab_ci_ymls) end - def gitlab_ci_syntax_ymls(project) - @gitlab_ci_syntax_ymls ||= TemplateFinder.all_template_names(project, :gitlab_ci_syntax_ymls) - end - def metrics_dashboard_ymls(project) @metrics_dashboard_ymls ||= TemplateFinder.all_template_names(project, :metrics_dashboard_ymls) end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 49963d14934..f72f8bfd151 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -10,7 +10,7 @@ module BoardsHelper boards_endpoint: @boards_endpoint, lists_endpoint: board_lists_path(board), board_id: board.id, - disabled: (!can?(current_user, :create_non_backlog_issues, board)).to_s, + disabled: board.disabled_for?(current_user).to_s, root_path: root_path, full_path: full_path, bulk_update_path: @bulk_issues_path, @@ -89,6 +89,10 @@ module BoardsHelper @current_board_parent ||= @group || @project end + def current_board_namespace + @current_board_namespace = board.group_board? ? @group : @project.namespace + end + def can_update? can?(current_user, :admin_issue, board) end @@ -136,4 +140,4 @@ module BoardsHelper end end -BoardsHelper.prepend_if_ee('EE::BoardsHelper') +BoardsHelper.prepend_mod_with('BoardsHelper') diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index 8f87cd5bfe0..a500a695029 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -22,4 +22,4 @@ module BranchesHelper end end -BranchesHelper.prepend_if_ee('EE::BranchesHelper') +BranchesHelper.prepend_mod_with('BranchesHelper') diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 1b00f583b55..27d6ee57d8b 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -100,4 +100,4 @@ module ButtonHelper end end -ButtonHelper.prepend_if_ee('EE::ButtonHelper') +ButtonHelper.prepend_mod_with('ButtonHelper') diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb index a0d169c1358..23f2a082a68 100644 --- a/app/helpers/ci/jobs_helper.rb +++ b/app/helpers/ci/jobs_helper.rb @@ -15,7 +15,8 @@ module Ci "build_stage" => @build.stage, "log_state" => '', "build_options" => javascript_build_options, - "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs') + "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings', anchor: 'retry-outdated-jobs'), + "code_quality_help_url" => help_page_path('user/project/merge_requests/code_quality', anchor: 'troubleshooting') } end @@ -36,4 +37,4 @@ module Ci end end -Ci::JobsHelper.prepend_if_ee('::EE::Ci::JobsHelper') +Ci::JobsHelper.prepend_mod_with('Ci::JobsHelper') diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb index ceb18d90c92..8c8ee2d4d0f 100644 --- a/app/helpers/ci/pipeline_editor_helper.rb +++ b/app/helpers/ci/pipeline_editor_helper.rb @@ -9,21 +9,29 @@ module Ci end def js_pipeline_editor_data(project) + commit_sha = project.commit ? project.commit.sha : '' { "ci-config-path": project.ci_config_path_or_default, - "commit-sha" => project.commit ? project.commit.sha : '', + "ci-examples-help-page-path" => help_page_path('ci/examples/README'), + "ci-help-page-path" => help_page_path('ci/README'), + "commit-sha" => commit_sha, "default-branch" => project.default_branch, "empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'), "initial-branch-name": params[:branch_name], "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), + "needs-help-page-path" => help_page_path('ci/yaml/README', anchor: 'needs'), "new-merge-request-path" => namespace_project_new_merge_request_path, + "pipeline_etag" => project.commit ? graphql_etag_pipeline_sha_path(commit_sha) : '', + "pipeline-page-path" => project_pipelines_path(project), "project-path" => project.path, "project-full-path" => project.full_path, "project-namespace" => project.namespace.full_path, + "runner-help-page-path" => help_page_path('ci/runners/README'), + "total-branches" => project.repository.branches.length, "yml-help-page-path" => help_page_path('ci/yaml/README') } end end end -Ci::PipelineEditorHelper.prepend_if_ee('EE::Ci::PipelineEditorHelper') +Ci::PipelineEditorHelper.prepend_mod_with('Ci::PipelineEditorHelper') diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index cabb43f45fd..f42cd53ae3a 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -50,10 +50,9 @@ module Ci { name: 'Gradle', logo: image_path('illustrations/logos/gradle.svg') }, { name: 'Grails', logo: image_path('illustrations/logos/grails.svg') }, { name: 'dotNET', logo: image_path('illustrations/logos/dotnet.svg') }, - { name: 'Rails', logo: image_path('illustrations/logos/rails.svg') }, { name: 'Julia', logo: image_path('illustrations/logos/julia.svg') }, { name: 'Laravel', logo: image_path('illustrations/logos/laravel.svg') }, - { name: 'Latex', logo: image_path('illustrations/logos/latex.svg') }, + { name: 'LaTeX', logo: image_path('illustrations/logos/latex.svg') }, { name: 'Maven', logo: image_path('illustrations/logos/maven.svg') }, { name: 'Mono', logo: image_path('illustrations/logos/mono.svg') }, { name: 'Nodejs', logo: image_path('illustrations/logos/node_js.svg') }, diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb index 82347053d6f..550fa4de2c5 100644 --- a/app/helpers/ci/runners_helper.rb +++ b/app/helpers/ci/runners_helper.rb @@ -75,4 +75,4 @@ module Ci end end -Ci::RunnersHelper.prepend_if_ee('EE::Ci::RunnersHelper') +Ci::RunnersHelper.prepend_mod_with('Ci::RunnersHelper') diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index e7a81eb5629..9b952ad127e 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -128,7 +128,7 @@ module CommitsHelper %w(btn gpg-status-box) + Array(additional_classes) end - def conditionally_paginate_diff_files(diffs, paginate:, per: Projects::CommitController::COMMIT_DIFFS_PER_PAGE) + def conditionally_paginate_diff_files(diffs, paginate:, per:) if paginate Kaminari.paginate_array(diffs.diff_files.to_a).page(params[:page]).per(per) else @@ -148,6 +148,27 @@ module CommitsHelper end end + # This is used to calculate a cache key for the app/views/projects/commits/_commit.html.haml + # partial. It takes some of the same parameters as used in the partial and will hash the + # current pipeline status. + # + # This includes a keyed hash for values that can be nil, to prevent invalid cache entries + # being served if the order should change in future. + def commit_partial_cache_key(commit, ref:, merge_request:, request:) + [ + commit, + commit.author, + ref, + { + merge_request: merge_request, + pipeline_status: commit.status_for(ref), + xhr: request.xhr?, + controller: controller.controller_path, + path: @path # referred to in #link_to_browse_code + } + ] + end + protected # Private: Returns a link to a person. If the person has a matching user and diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index 08f357916b5..95bbf2eff41 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -66,4 +66,4 @@ module DashboardHelper end end -DashboardHelper.prepend_if_ee('EE::DashboardHelper') +DashboardHelper.prepend_mod_with('DashboardHelper') diff --git a/app/helpers/dev_ops_report_helper.rb b/app/helpers/dev_ops_report_helper.rb index ab7e56fc1a2..c2200a4c3da 100644 --- a/app/helpers/dev_ops_report_helper.rb +++ b/app/helpers/dev_ops_report_helper.rb @@ -1,18 +1,80 @@ # frozen_string_literal: true module DevOpsReportHelper + def devops_score_metrics(metric) + return {} if metric.blank? + + { + averageScore: average_score_data(metric), + cards: devops_score_card_data(metric), + createdAt: metric.created_at.strftime('%Y-%m-%d %H:%M') + } + end + + private + + def format_score(score) + precision = score < 1 ? 2 : 1 + number_with_precision(score, precision: precision) + end + def score_level(score) if score < 33.33 - 'low' + { + label: s_('DevopsReport|Low'), + variant: 'muted' + } elsif score < 66.66 - 'average' + { + label: s_('DevopsReport|Moderate'), + variant: 'neutral' + } else - 'high' + { + label: s_('DevopsReport|High'), + variant: 'success' + } end end - def format_score(score) - precision = score < 1 ? 2 : 1 - number_with_precision(score, precision: precision) + def average_score_level(score) + if score < 33.33 + { + label: s_('DevopsReport|Low'), + variant: 'danger', + icon: 'status-failed' + } + elsif score < 66.66 + { + label: s_('DevopsReport|Moderate'), + variant: 'warning', + icon: 'status-alert' + } + else + { + label: s_('DevopsReport|High'), + variant: 'success', + icon: 'status_success_solid' + } + end + end + + def average_score_data(metric) + { + value: format_score(metric.average_percentage_score), + scoreLevel: average_score_level(metric.average_percentage_score) + } + end + + def devops_score_card_data(metric) + metric.cards.map do |card| + { + title: "#{card.title} #{card.description}", + usage: format_score(card.instance_score), + leadInstance: format_score(card.leader_score), + score: format_score(card.percentage_score), + scoreLevel: score_level(card.percentage_score) + } + end end end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 7bf3cb6230b..e430b0f402b 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -23,14 +23,16 @@ module DiffHelper end end + def show_only_context_commits? + !!params[:only_context_commits] || @merge_request&.commits&.empty? + end + def diff_options options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded? } if action_name == 'diff_for_path' options[:expanded] = true options[:paths] = params.values_at(:old_path, :new_path) - elsif action_name == 'show' - options[:include_context_commits] = true unless @project.context_commits_enabled? end options diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index b58ff21b257..0b1bdb68e50 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -298,4 +298,4 @@ module EmailsHelper end end -EmailsHelper.prepend_if_ee('EE::EmailsHelper') +EmailsHelper.prepend_mod_with('EmailsHelper') diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 7f0c59f65a0..594c6fedef1 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -34,7 +34,7 @@ module EnvironmentsHelper def environment_logs_data(project, environment) { "environment_name": environment.name, - "environments_path": project_environments_path(project, format: :json), + "environments_path": api_v4_projects_environments_path(id: project.id), "environment_id": environment.id, "cluster_applications_documentation_path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack'), "clusters_path": project_clusters_path(project, format: :json) @@ -62,7 +62,8 @@ module EnvironmentsHelper 'validate_query_path' => validate_query_project_prometheus_metrics_path(project), 'custom_metrics_available' => "#{custom_metrics_available?(project)}", 'prometheus_alerts_available' => "#{can?(current_user, :read_prometheus_alerts, project)}", - 'dashboard_timezone' => project.metrics_setting_dashboard_timezone.to_s.upcase + 'dashboard_timezone' => project.metrics_setting_dashboard_timezone.to_s.upcase, + 'has_managed_prometheus' => has_managed_prometheus?(project).to_s } end @@ -78,6 +79,10 @@ module EnvironmentsHelper } end + def has_managed_prometheus?(project) + project.prometheus_service&.prometheus_available? == true + end + def metrics_dashboard_base_path(environment, project) # This is needed to support our transition from environment scoped metric paths to project scoped. if project @@ -117,4 +122,4 @@ module EnvironmentsHelper end end -EnvironmentsHelper.prepend_if_ee('::EE::EnvironmentsHelper') +EnvironmentsHelper.prepend_mod_with('EnvironmentsHelper') diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 6b3abb4274e..03c3ee3363d 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -228,7 +228,7 @@ module EventsHelper def event_commit_title(message) message ||= '' (message.split("\n").first || "").truncate(70) - rescue + rescue StandardError "--broken encoding" end @@ -290,4 +290,4 @@ module EventsHelper end end -EventsHelper.prepend_if_ee('EE::EventsHelper') +EventsHelper.prepend_mod_with('EventsHelper') diff --git a/app/helpers/export_helper.rb b/app/helpers/export_helper.rb index 38a4f7f1b4b..92d06471384 100644 --- a/app/helpers/export_helper.rb +++ b/app/helpers/export_helper.rb @@ -25,4 +25,4 @@ module ExportHelper end end -ExportHelper.prepend_if_ee('EE::ExportHelper') +ExportHelper.prepend_mod_with('ExportHelper') diff --git a/app/helpers/feature_flags_helper.rb b/app/helpers/feature_flags_helper.rb index e50191a471f..2b8804bc07e 100644 --- a/app/helpers/feature_flags_helper.rb +++ b/app/helpers/feature_flags_helper.rb @@ -16,4 +16,4 @@ module FeatureFlagsHelper end end -FeatureFlagsHelper.prepend_if_ee('::EE::FeatureFlagsHelper') +FeatureFlagsHelper.prepend_mod_with('FeatureFlagsHelper') diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index d0276c91316..cf3e99eee49 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -131,4 +131,4 @@ module FormHelper end end -FormHelper.prepend_if_ee('::EE::FormHelper') +FormHelper.prepend_mod_with('FormHelper') diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 48af4793fb0..0a684d92eb1 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -166,6 +166,16 @@ module GitlabRoutingHelper resend_invite_group_group_member_path(group_member.source, group_member) end + # Members + def source_members_url(member) + case member.source_type + when 'Namespace' + group_group_members_url(member.source) + when 'Project' + project_project_members_url(member.source) + end + end + # Artifacts # Rails path generators are slow because they need to do large regex comparisons @@ -354,6 +364,10 @@ module GitlabRoutingHelper [api_graphql_path, "pipelines/id/#{pipeline.id}"].join(':') end + def graphql_etag_pipeline_sha_path(sha) + [api_graphql_path, "pipelines/sha/#{sha}"].join(':') + end + private def snippet_query_params(snippet, *args) @@ -370,4 +384,4 @@ module GitlabRoutingHelper end end -GitlabRoutingHelper.include_if_ee('EE::GitlabRoutingHelper') +GitlabRoutingHelper.include_mod_with('GitlabRoutingHelper') diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb index bcbc67957eb..3a94f7d47c2 100644 --- a/app/helpers/graph_helper.rb +++ b/app/helpers/graph_helper.rb @@ -28,4 +28,4 @@ module GraphHelper end end -GraphHelper.prepend_if_ee('EE::GraphHelper') +GraphHelper.prepend_mod_with('GraphHelper') diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb index 3e7d6febabf..79191616c8f 100644 --- a/app/helpers/groups/group_members_helper.rb +++ b/app/helpers/groups/group_members_helper.rb @@ -13,31 +13,45 @@ module Groups::GroupMembersHelper render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: group.access_level_roles, default_access_level: default_access_level end - def group_group_links_data_json(group_links) - GroupLink::GroupGroupLinkSerializer.new.represent(group_links, { current_user: current_user }).to_json + def group_members_list_data_json(group, members, pagination = {}) + group_members_list_data(group, members, pagination).to_json end - def members_data_json(group, members) - MemberSerializer.new.represent(members, { current_user: current_user, group: group, source: group }).to_json + def group_group_links_list_data_json(group) + group_group_links_list_data(group).to_json + end + + private + + def group_members_serialized(group, members) + MemberSerializer.new.represent(members, { current_user: current_user, group: group, source: group }) + end + + def group_group_links_serialized(group_links) + GroupLink::GroupGroupLinkSerializer.new.represent(group_links, { current_user: current_user }) end # Overridden in `ee/app/helpers/ee/groups/group_members_helper.rb` - def group_members_list_data_attributes(group, members) + def group_members_list_data(group, members, pagination) { - members: members_data_json(group, members), + members: group_members_serialized(group, members), + pagination: members_pagination_data(members, pagination), member_path: group_group_member_path(group, ':id'), source_id: group.id, - can_manage_members: can?(current_user, :admin_group_member, group).to_s + can_manage_members: can?(current_user, :admin_group_member, group) } end - def group_group_links_list_data_attributes(group) + def group_group_links_list_data(group) + group_links = group.shared_with_group_links + { - members: group_group_links_data_json(group.shared_with_group_links), + members: group_group_links_serialized(group_links), + pagination: members_pagination_data(group_links), member_path: group_group_link_path(group, ':id'), source_id: group.id } end end -Groups::GroupMembersHelper.prepend_if_ee('EE::Groups::GroupMembersHelper') +Groups::GroupMembersHelper.prepend_mod_with('Groups::GroupMembersHelper') diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 5ce23baa226..8f647a49a64 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -7,7 +7,11 @@ module GroupsHelper groups#details groups#activity groups#subgroups - ] + ].tap do |paths| + break paths if Feature.disabled?(:sidebar_refactor, current_user, default_enabled: :yaml) + + paths.concat(['labels#index', 'group_members#index']) + end end def group_settings_nav_link_paths @@ -25,7 +29,9 @@ module GroupsHelper applications#index applications#show applications#edit - packages_and_registries#index + packages_and_registries#show + groups/runners#show + groups/runners#edit ] end @@ -36,6 +42,14 @@ module GroupsHelper ] end + def group_information_title(group) + if Feature.enabled?(:sidebar_refactor, current_user) + group.subgroup? ? _('Subgroup information') : _('Group information') + else + group.subgroup? ? _('Subgroup overview') : _('Group overview') + end + end + def group_container_registry_nav? Gitlab.config.registry.enabled && can?(current_user, :read_container_image, @group) @@ -113,9 +127,7 @@ module GroupsHelper @has_group_title = true full_title = [] - ancestors = group.ancestors.with_route - - ancestors.reverse_each.with_index do |parent, index| + sorted_ancestors(group).with_route.reverse_each.with_index do |parent, index| if index > 0 add_to_breadcrumb_dropdown(group_title_link(parent, hidable: false, show_avatar: true, for_dropdown: true), location: :before) else @@ -141,9 +153,9 @@ module GroupsHelper def projects_lfs_status(group) lfs_status = if group.lfs_enabled? - group.projects.select(&:lfs_enabled?).size + group.projects.count(&:lfs_enabled?) else - group.projects.reject(&:lfs_enabled?).size + group.projects.count { |project| !project.lfs_enabled? } end size = group.projects.size @@ -206,10 +218,9 @@ module GroupsHelper end def show_invite_banner?(group) - Feature.enabled?(:invite_your_teammates_banner_a, group) && - can?(current_user, :admin_group, group) && - !just_created? && - !multiple_members?(group) + can?(current_user, :admin_group, group) && + !just_created? && + !multiple_members?(group) end def render_setting_to_allow_project_access_token_creation?(group) @@ -231,7 +242,7 @@ module GroupsHelper end def multiple_members?(group) - group.member_count > 1 + group.member_count > 1 || group.members_with_parents.count > 1 end def get_group_sidebar_links @@ -285,11 +296,20 @@ module GroupsHelper end def oldest_consecutively_locked_ancestor(group) - group.ancestors.find do |group| + sorted_ancestors(group).find do |group| !group.has_parent? || !group.parent.share_with_group_lock? end end + # Ancestors sorted by hierarchy depth in bottom-top order. + def sorted_ancestors(group) + if group.root_ancestor.use_traversal_ids? + group.ancestors(hierarchy_order: :asc) + else + group.ancestors + end + end + def default_help s_("GroupSettings|This setting will be applied to all subgroups unless overridden by a group owner. Groups that already have access to the project will continue to have access unless removed manually.") end @@ -327,4 +347,4 @@ module GroupsHelper end end -GroupsHelper.prepend_if_ee('EE::GroupsHelper') +GroupsHelper.prepend_mod_with('GroupsHelper') diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb index 9466a37ed93..2725d28c47c 100644 --- a/app/helpers/hooks_helper.rb +++ b/app/helpers/hooks_helper.rb @@ -38,4 +38,4 @@ module HooksHelper end end -HooksHelper.prepend_if_ee('EE::HooksHelper') +HooksHelper.prepend_mod_with('HooksHelper') diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 61d8d0f779d..d1c84bd4141 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -17,7 +17,8 @@ module IdeHelper 'file-path' => @path, 'merge-request' => @merge_request, 'fork-info' => @fork_info&.to_json, - 'project' => convert_to_project_entity_json(@project) + 'project' => convert_to_project_entity_json(@project), + 'enable-environments-guidance' => enable_environments_guidance?.to_s } end @@ -28,6 +29,18 @@ module IdeHelper API::Entities::Project.represent(project).to_json end + + def enable_environments_guidance? + experiment(:in_product_guidance_environments_webide, project: @project) do |e| + e.try { !has_dismissed_ide_environments_callout? } + + e.run + end + end + + def has_dismissed_ide_environments_callout? + current_user.dismissed_callout?(feature_name: 'web_ide_ci_environments_guidance') + end end -::IdeHelper.prepend_if_ee('::EE::IdeHelper') +::IdeHelper.prepend_mod_with('IdeHelper') diff --git a/app/helpers/in_product_marketing_helper.rb b/app/helpers/in_product_marketing_helper.rb index 9e59a04d709..09546f251f9 100644 --- a/app/helpers/in_product_marketing_helper.rb +++ b/app/helpers/in_product_marketing_helper.rb @@ -1,381 +1,12 @@ # frozen_string_literal: true module InProductMarketingHelper - def subject_line(track, series) - { - create: [ - s_('InProductMarketing|Create a project in GitLab in 5 minutes'), - s_('InProductMarketing|Import your project and code from GitHub, Bitbucket and others'), - s_('InProductMarketing|Understand repository mirroring') - ], - verify: [ - s_('InProductMarketing|Feel the need for speed?'), - s_('InProductMarketing|3 ways to dive into GitLab CI/CD'), - s_('InProductMarketing|Explore the power of GitLab CI/CD') - ], - trial: [ - s_('InProductMarketing|Go farther with GitLab'), - s_('InProductMarketing|Automated security scans directly within GitLab'), - s_('InProductMarketing|Take your source code management to the next level') - ], - team: [ - s_('InProductMarketing|Working in GitLab = more efficient'), - s_("InProductMarketing|Multiple owners, confusing workstreams? We've got you covered"), - s_('InProductMarketing|Your teams can be more efficient') - ] - }[track][series] - end - - def in_product_marketing_logo(track, series) - inline_image_link('mailers/in_product_marketing', "#{track}-#{series}.png", { width: '150', style: 'width: 150px;' }) - end - - def about_link(folder, image, width) - link_to inline_image_link(folder, image, { width: width, style: "width: #{width}px;", alt: s_('InProductMarketing|go to about.gitlab.com') }), 'https://about.gitlab.com/' - end - - def in_product_marketing_tagline(track, series) - { - create: [ - s_('InProductMarketing|Get started today'), - s_('InProductMarketing|Get our import guides'), - s_('InProductMarketing|Need an alternative to importing?') - ], - verify: [ - s_('InProductMarketing|Use GitLab CI/CD'), - s_('InProductMarketing|Test, create, deploy'), - s_('InProductMarketing|Are your runners ready?') - ], - trial: [ - s_('InProductMarketing|Start a free trial of GitLab Ultimate – no CC required'), - s_('InProductMarketing|Improve app security with a 30-day trial'), - s_('InProductMarketing|Start with a GitLab Ultimate free trial') - ], - team: [ - s_('InProductMarketing|Invite your colleagues to join in less than one minute'), - s_('InProductMarketing|Get your team set up on GitLab'), - nil - ] - }[track][series] - end - - def in_product_marketing_title(track, series) - { - create: [ - s_('InProductMarketing|Take your first steps with GitLab'), - s_('InProductMarketing|Start by importing your projects'), - s_('InProductMarketing|How (and why) mirroring makes sense') - ], - verify: [ - s_('InProductMarketing|Rapid development, simplified'), - s_('InProductMarketing|Get started with GitLab CI/CD'), - s_('InProductMarketing|Launch GitLab CI/CD in 20 minutes or less') - ], - trial: [ - s_('InProductMarketing|Give us one minute...'), - s_("InProductMarketing|Security that's integrated into your development lifecycle"), - s_('InProductMarketing|Improve code quality and streamline reviews') - ], - team: [ - s_('InProductMarketing|Team work makes the dream work'), - s_('InProductMarketing|*GitLab*, noun: a synonym for efficient teams'), - s_('InProductMarketing|Find out how your teams are really doing') - ] - }[track][series] - end - - def in_product_marketing_subtitle(track, series) - { - create: [ - s_('InProductMarketing|Dig in and create a project and a repo'), - s_("InProductMarketing|Here's what you need to know"), - s_('InProductMarketing|Try it out') - ], - verify: [ - s_('InProductMarketing|How to build and test faster'), - s_('InProductMarketing|Explore the options'), - s_('InProductMarketing|Follow our steps') - ], - trial: [ - s_('InProductMarketing|...and you can get a free trial of GitLab Ultimate'), - s_('InProductMarketing|Try GitLab Ultimate for free'), - s_('InProductMarketing|Better code in less time') - ], - team: [ - s_('InProductMarketing|Actually, GitLab makes the team work (better)'), - s_('InProductMarketing|Our tool brings all the things together'), - s_("InProductMarketing|It's all in the stats") - ] - }[track][series] - end - - def in_product_marketing_body_line1(track, series, format: nil) - { - create: [ - s_("InProductMarketing|To understand and get the most out of GitLab, start at the beginning and %{project_link}. In GitLab, repositories are part of a project, so after you've created your project you can go ahead and %{repo_link}.") % { project_link: project_link(format), repo_link: repo_link(format) }, - s_("InProductMarketing|Making the switch? It's easier than you think to import your projects into GitLab. Move %{github_link}, or import something %{bitbucket_link}.") % { github_link: github_link(format), bitbucket_link: bitbucket_link(format) }, - s_("InProductMarketing|Sometimes you're not ready to make a full transition to a new tool. If you're not ready to fully commit, %{mirroring_link} gives you a safe way to try out GitLab in parallel with your current tool.") % { mirroring_link: mirroring_link(format) } - ], - verify: [ - s_("InProductMarketing|Tired of wrestling with disparate tool chains, information silos and inefficient processes? GitLab's CI/CD is built on a DevOps platform with source code management, planning, monitoring and more ready to go. Find out %{ci_link}.") % { ci_link: ci_link(format) }, - s_("InProductMarketing|GitLab's CI/CD makes software development easier. Don't believe us? Here are three ways you can take it for a fast (and satisfying) test drive:"), - s_("InProductMarketing|Get going with CI/CD quickly using our %{quick_start_link}. Start with an available runner and then create a CI .yml file – it's really that easy.") % { quick_start_link: quick_start_link(format) } - ], - trial: [ - [ - s_("InProductMarketing|GitLab's premium tiers are designed to make you, your team and your application more efficient and more secure with features including but not limited to:"), - list([ - s_('InProductMarketing|%{strong_start}Company wide portfolio management%{strong_end} — including multi-level epics, scoped labels').html_safe % strong_options(format), - s_('InProductMarketing|%{strong_start}Multiple approval roles%{strong_end} — including code owners and required merge approvals').html_safe % strong_options(format), - s_('InProductMarketing|%{strong_start}Advanced application security%{strong_end} — including SAST, DAST scanning, FUZZ testing, dependency scanning, license compliance, secrete detection').html_safe % strong_options(format), - s_('InProductMarketing|%{strong_start}Executive level insights%{strong_end} — including reporting on productivity, tasks by type, days to completion, value stream').html_safe % strong_options(format) - ], format) - ].join("\n"), - s_('InProductMarketing|GitLab provides static application security testing (SAST), dynamic application security testing (DAST), container scanning, and dependency scanning to help you deliver secure applications along with license compliance.'), - s_('InProductMarketing|By enabling code owners and required merge approvals the right person will review the right MR. This is a win-win: cleaner code and a more efficient review process.') - ], - team: [ - [ - s_('InProductMarketing|Did you know teams that use GitLab are far more efficient?'), - list([ - s_('InProductMarketing|Goldman Sachs went from 1 build every two weeks to thousands of builds a day'), - s_('InProductMarketing|Ticketmaster decreased their CI build time by 15X') - ], format) - ].join("\n"), - s_("InProductMarketing|We know a thing or two about efficiency and we don't want to keep that to ourselves. Sign up for a free trial of GitLab Ultimate and your teams will be on it from day one."), - [ - s_('InProductMarketing|Stop wondering and use GitLab to answer questions like:'), - list([ - s_('InProductMarketing|How long does it take us to close issues/MRs by types like feature requests, bugs, tech debt, security?'), - s_('InProductMarketing|How many days does it take our team to complete various tasks?'), - s_('InProductMarketing|What does our value stream timeline look like from product to development to review and production?') - ], format) - ].join("\n") - ] - }[track][series] - end - - def in_product_marketing_body_line2(track, series, format: nil) - { - create: [ - s_("InProductMarketing|That's all it takes to get going with GitLab, but if you're new to working with Git, check out our %{basics_link} for helpful tips and tricks for getting started.") % { basics_link: basics_link(format) }, - s_("InProductMarketing|Have a different instance you'd like to import? Here's our %{import_link}.") % { import_link: import_link(format) }, - s_("InProductMarketing|It's also possible to simply %{external_repo_link} in order to take advantage of GitLab's CI/CD.") % { external_repo_link: external_repo_link(format) } - ], - verify: [ - nil, - list([ - s_('InProductMarketing|Start by %{performance_link}').html_safe % { performance_link: performance_link(format) }, - s_('InProductMarketing|Move on to easily creating a Pages website %{ci_template_link}').html_safe % { ci_template_link: ci_template_link(format) }, - s_('InProductMarketing|And finally %{deploy_link} a Python application.').html_safe % { deploy_link: deploy_link(format) } - ], format), - nil - ], - trial: [ - s_('InProductMarketing|Start a GitLab Ultimate trial today in less than one minute, no credit card required.'), - s_('InProductMarketing|Get started today with a 30-day GitLab Ultimate trial, no credit card required.'), - s_('InProductMarketing|Code owners and required merge approvals are part of the paid tiers of GitLab. You can start a free 30-day trial of GitLab Ultimate and enable these features in less than 5 minutes with no credit card required.') - ], - team: [ - s_('InProductMarketing|Invite your colleagues and start shipping code faster.'), - s_("InProductMarketing|Streamline code review, know at a glance who's unavailable, communicate in comments or in email and integrate with Slack so everyone's on the same page."), - s_('InProductMarketing|When your team is on GitLab these answers are a click away.') - ] - }[track][series] - end - - def cta_link(track, series, group, format: nil) - case format - when :html - link_to in_product_marketing_cta_text(track, series), group_email_campaigns_url(group, track: track, series: series), target: '_blank', rel: 'noopener noreferrer' - else - [in_product_marketing_cta_text(track, series), group_email_campaigns_url(group, track: track, series: series)].join(' >> ') - end - end - - def in_product_marketing_progress(track, series, format: nil) - if Gitlab.com? - s_('InProductMarketing|This is email %{series} of 3 in the %{track} series.') % { series: series + 1, track: track.to_s.humanize } - else - s_('InProductMarketing|This is email %{series} of 3 in the %{track} series. To disable notification emails sent by your local GitLab instance, either contact your administrator or %{unsubscribe_link}.') % { series: series + 1, track: track.to_s.humanize, unsubscribe_link: unsubscribe_link(format) } - end - end - - def footer_links(format: nil) - links = [ - [s_('InProductMarketing|Blog'), 'https://about.gitlab.com/blog'], - [s_('InProductMarketing|Twitter'), 'https://twitter.com/gitlab'], - [s_('InProductMarketing|Facebook'), 'https://www.facebook.com/gitlab'], - [s_('InProductMarketing|YouTube'), 'https://www.youtube.com/channel/UCnMGQ8QHMAnVIsI3xJrihhg'] - ] - case format - when :html - links.map do |text, link| - link_to(text, link) - end - else - '| ' + links.map do |text, link| - [text, link].join(' ') - end.join("\n| ") - end - end - - def address(format: nil) - s_('InProductMarketing|%{strong_start}GitLab Inc.%{strong_end} 268 Bush Street, #350, San Francisco, CA 94104, USA').html_safe % strong_options(format) - end - - def unsubscribe(track, series, format: nil) - parts = Gitlab.com? ? unsubscribe_com(format) : unsubscribe_self_managed(track, series, format) - - case format - when :html - parts.join(' ') - else - parts.join("\n" + ' ' * 16) - end - end - - private - - def unsubscribe_com(format) - [ - s_('InProductMarketing|If you no longer wish to receive marketing emails from us,'), - s_('InProductMarketing|you may %{unsubscribe_link} at any time.') % { unsubscribe_link: unsubscribe_link(format) } - ] - end - - def unsubscribe_self_managed(track, series, format) - [ - s_('InProductMarketing|To opt out of these onboarding emails, %{unsubscribe_link}.') % { unsubscribe_link: unsubscribe_link(format) }, - s_("InProductMarketing|If you don't want to receive marketing emails directly from GitLab, %{marketing_preference_link}.") % { marketing_preference_link: marketing_preference_link(track, series, format) } - ] - end - - def in_product_marketing_cta_text(track, series) - { - create: [ - s_('InProductMarketing|Create your first project!'), - s_('InProductMarketing|Master the art of importing!'), - s_('InProductMarketing|Understand your project options') - ], - verify: [ - s_('InProductMarketing|Get to know GitLab CI/CD'), - s_('InProductMarketing|Try it yourself'), - s_('InProductMarketing|Explore GitLab CI/CD') - ], - trial: [ - s_('InProductMarketing|Start a trial'), - s_('InProductMarketing|Beef up your security'), - s_('InProductMarketing|Start your trial now!') - ], - team: [ - s_('InProductMarketing|Invite your colleagues today'), - s_('InProductMarketing|Invite your team in less than 60 seconds'), - s_('InProductMarketing|Invite your team now') - ] - }[track][series] - end - - def project_link(format) - link(s_('InProductMarketing|create a project'), help_page_url('gitlab-basics/create-project'), format) - end - - def repo_link(format) - link(s_('InProductMarketing|set up a repo'), help_page_url('user/project/repository/index', anchor: 'create-a-repository'), format) - end - - def github_link(format) - link(s_('InProductMarketing|GitHub Enterprise projects to GitLab'), help_page_url('integration/github'), format) - end - - def bitbucket_link(format) - link(s_('InProductMarketing|from Bitbucket'), help_page_url('user/project/import/bitbucket_server'), format) - end - - def mirroring_link(format) - link(s_('InProductMarketing|repository mirroring'), help_page_url('user/project/repository/repository_mirroring'), format) - end - - def ci_link(format) - link(s_('InProductMarketing|how easy it is to get started'), help_page_url('ci/README'), format) - end - - def performance_link(format) - link(s_('InProductMarketing|testing browser performance'), help_page_url('user/project/merge_requests/browser_performance_testing'), format) - end - - def ci_template_link(format) - link(s_('InProductMarketing|using a CI/CD template'), help_page_url('user/project/pages/getting_started/pages_ci_cd_template'), format) - end - - def deploy_link(format) - link(s_('InProductMarketing|test and deploy'), help_page_url('ci/examples/test-and-deploy-python-application-to-heroku'), format) - end - - def quick_start_link(format) - link(s_('InProductMarketing|quick start guide'), help_page_url('ci/quick_start/README'), format) - end - - def basics_link(format) - link(s_('InProductMarketing|Git basics'), help_page_url('gitlab-basics/README'), format) - end - - def import_link(format) - link(s_('InProductMarketing|comprehensive guide'), help_page_url('user/project/import/index'), format) - end - - def external_repo_link(format) - link(s_('InProductMarketing|connect an external repository'), new_project_url(anchor: 'cicd_for_external_repo'), format) - end - - def unsubscribe_link(format) - unsubscribe_url = Gitlab.com? ? '%tag_unsubscribe_url%' : profile_notifications_url - - link(s_('InProductMarketing|unsubscribe'), unsubscribe_url, format) - end - - def marketing_preference_link(track, series, format) - params = { - utm_source: 'SM', - utm_medium: 'email', - utm_campaign: 'onboarding', - utm_term: "#{track}_#{series}" - } - - preference_link = "https://about.gitlab.com/company/preference-center/?#{params.to_query}" - - link(s_('InProductMarketing|update your preferences'), preference_link, format) - end - - def link(text, link, format) - case format - when :html - link_to text, link - else - "#{text} (#{link})" - end - end - - def list(array, format) - case format - when :html - tag.ul { array.map { |item| concat tag.li item} } - else - '- ' + array.join("\n- ") - end - end - - def strong_options(format) - case format - when :html - { strong_start: '<b>'.html_safe, strong_end: '</b>'.html_safe } - else - { strong_start: '', strong_end: '' } - end + def inline_image_link(image, options) + attachments.inline[image] = File.read(Rails.root.join("app/assets/images", image)) + image_tag attachments[image].url, **options end - def inline_image_link(folder, image, options) - attachments.inline[image] = File.read(Rails.root.join("app/assets/images", folder, image)) - image_tag attachments[image].url, **options + def about_link(image, width) + link_to inline_image_link(image, { width: width, style: "width: #{width}px;", alt: s_('InProductMarketing|go to about.gitlab.com') }), 'https://about.gitlab.com/' end end diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb index 62d83ebe79e..889c058cb21 100644 --- a/app/helpers/invite_members_helper.rb +++ b/app/helpers/invite_members_helper.rb @@ -3,12 +3,12 @@ module InviteMembersHelper include Gitlab::Utils::StrongMemoize - def can_invite_members_for_group?(group) - Feature.enabled?(:invite_members_group_modal, group) && can?(current_user, :admin_group_member, group) + def can_invite_members_for_project?(project) + Feature.enabled?(:invite_members_group_modal, project.group) && can_manage_project_members?(project) end - def can_invite_members_for_project?(project) - Feature.enabled?(:invite_members_group_modal, project.group) && can_import_members? + def can_invite_group_for_project?(project) + Feature.enabled?(:invite_members_group_modal, project.group) && project.allowed_to_share_with_group? end def directly_invite_members? @@ -17,20 +17,6 @@ module InviteMembersHelper end end - def indirectly_invite_members? - strong_memoize(:indirectly_invite_members) do - experiment_enabled?(:invite_members_version_b) && !can_import_members? - end - end - - def show_invite_members_track_event - if directly_invite_members? - 'show_invite_members' - elsif indirectly_invite_members? - 'show_invite_members_version_b' - end - end - def invite_group_members?(group) experiment_enabled?(:invite_members_empty_group_version_a) && Ability.allowed?(current_user, :admin_group_member, group) end @@ -46,6 +32,17 @@ module InviteMembersHelper end end + def invite_accepted_notice(member) + case member.source + when Project + _("You have been granted %{member_human_access} access to project %{name}.") % + { member_human_access: member.human_access, name: member.source.name } + when Group + _("You have been granted %{member_human_access} access to group %{name}.") % + { member_human_access: member.human_access, name: member.source.name } + end + end + private def invite_members_url(form_model) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 8ebc773bb25..c662dabe453 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -199,7 +199,7 @@ module IssuablesHelper count = issuables_count_for_state(issuable_type, state) if count != -1 - html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge badge-pill') + html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm') end html.html_safe @@ -332,6 +332,18 @@ module IssuablesHelper end end + def state_name_with_icon(issuable) + if issuable.is_a?(MergeRequest) && issuable.merged? + [_("Merged"), "git-merge"] + elsif issuable.is_a?(MergeRequest) && issuable.closed? + [_("Closed"), "close"] + elsif issuable.closed? + [_("Closed"), "mobile-issue-close"] + else + [_("Open"), "issue-open-m"] + end + end + private def sidebar_gutter_collapsed? @@ -386,11 +398,11 @@ module IssuablesHelper rootPath: root_path, fullPath: issuable[:project_full_path], iid: issuable[:iid], + id: issuable[:id], severity: issuable[:severity], timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours, createNoteEmail: issuable[:create_note_email], - issuableType: issuable[:type], - projectMembersPath: project_project_members_path(@project, sort: :access_level_desc) + issuableType: issuable[:type] } end @@ -414,4 +426,4 @@ module IssuablesHelper end end -IssuablesHelper.prepend_if_ee('EE::IssuablesHelper') +IssuablesHelper.prepend_mod_with('IssuablesHelper') diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 0a83e707412..1449725fb2b 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -9,6 +9,22 @@ module IssuesHelper classes.join(' ') end + def issue_manual_ordering_class + is_sorting_by_relative_position = @sort == 'relative_position' + + if is_sorting_by_relative_position && !issue_repositioning_disabled? + "manual-ordering" + end + end + + def issue_repositioning_disabled? + if @group + @group.root_ancestor.issue_repositioning_disabled? + elsif @project + @project.root_namespace.issue_repositioning_disabled? + end + end + def status_box_class(item) if item.try(:expired?) 'status-box-expired' @@ -165,23 +181,32 @@ module IssuesHelper def issues_list_data(project, current_user, finder) { + autocomplete_users_path: autocomplete_users_path(active: true, current_user: true, project_id: project.id, format: :json), + autocomplete_award_emojis_path: autocomplete_award_emojis_path, calendar_path: url_for(safe_params.merge(calendar_url_options)), can_bulk_update: can?(current_user, :admin_issue, project).to_s, can_edit: can?(current_user, :admin_project, project).to_s, can_import_issues: can?(current_user, :import_issues, @project).to_s, email: current_user&.notification_email, + emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), empty_state_svg_path: image_path('illustrations/issues.svg'), endpoint: expose_path(api_v4_projects_issues_path(id: project.id)), export_csv_path: export_csv_project_issues_path(project), - full_path: project.full_path, has_issues: project_issues(project).exists?.to_s, import_csv_issues_path: import_csv_namespace_project_issues_path, + initial_email: project.new_issuable_address(current_user, 'issue'), is_signed_in: current_user.present?.to_s, issues_path: project_issues_path(project), jira_integration_path: help_page_url('user/project/integrations/jira', anchor: 'view-jira-issues'), + markdown_help_path: help_page_path('user/markdown'), max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes), new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.try(:id), milestone_id: finder.milestones.first.try(:id) }), project_import_jira_path: project_import_jira_path(project), + project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json), + project_milestones_path: project_milestones_path(project, format: :json), + project_path: project.full_path, + quick_actions_help_path: help_page_path('user/project/quick_actions'), + reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'), rss_path: url_for(safe_params.merge(rss_url_options)), show_new_issue_link: show_new_issue_link?(project).to_s, sign_in_path: new_user_session_path @@ -200,4 +225,4 @@ module IssuesHelper end end -IssuesHelper.prepend_if_ee('EE::IssuesHelper') +IssuesHelper.prepend_mod_with('IssuesHelper') diff --git a/app/helpers/kerberos_spnego_helper.rb b/app/helpers/kerberos_spnego_helper.rb index ed09ed755fe..0f6812bc31b 100644 --- a/app/helpers/kerberos_spnego_helper.rb +++ b/app/helpers/kerberos_spnego_helper.rb @@ -10,4 +10,4 @@ module KerberosSpnegoHelper end end -KerberosSpnegoHelper.prepend_if_ee('EE::KerberosSpnegoHelper') +KerberosSpnegoHelper.prepend_mod_with('KerberosSpnegoHelper') diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index cfc4075100b..2150729cb2a 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -275,4 +275,4 @@ module LabelsHelper end end -LabelsHelper.prepend_if_ee('EE::LabelsHelper') +LabelsHelper.prepend_mod_with('LabelsHelper') diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb index 81896fb9fa4..a3a8a275f67 100644 --- a/app/helpers/learn_gitlab_helper.rb +++ b/app/helpers/learn_gitlab_helper.rb @@ -3,11 +3,21 @@ module LearnGitlabHelper def learn_gitlab_experiment_enabled?(project) return false unless current_user - return false unless experiment_enabled_for_user? + return false unless continous_onboarding_experiment_enabled_for_user? learn_gitlab_onboarding_available?(project) end + def learn_gitlab_experiment_tracking_category + return unless current_user + + if Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_a, subject: current_user) + Gitlab::Experimentation.get_experiment(:learn_gitlab_a).tracking_category + elsif Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_b, subject: current_user) + Gitlab::Experimentation.get_experiment(:learn_gitlab_b).tracking_category + end + end + def onboarding_actions_data(project) attributes = onboarding_progress(project).attributes.symbolize_keys @@ -21,42 +31,42 @@ module LearnGitlabHelper end end - private + def continous_onboarding_experiment_enabled_for_user? + Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_a, subject: current_user) || + Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_b, subject: current_user) + end - ACTION_ISSUE_IDS = { - issue_created: 4, - git_write: 6, - pipeline_created: 7, - merge_request_created: 9, - user_added: 8, - trial_started: 2, - required_mr_approvals_enabled: 11, - code_owners_enabled: 10 - }.freeze - - ACTION_DOC_URLS = { - security_scan_enabled: 'https://docs.gitlab.com/ee/user/application_security/security_dashboard/#gitlab-security-dashboard-security-center-and-vulnerability-reports' - }.freeze + def onboarding_sections_data + { + workspace: { + svg: image_path("learn_gitlab/section_workspace.svg") + }, + plan: { + svg: image_path("learn_gitlab/section_plan.svg") + }, + deploy: { + svg: image_path("learn_gitlab/section_deploy.svg") + } + } + end + + def learn_gitlab_onboarding_available?(project) + OnboardingProgress.onboarding?(project.namespace) && + LearnGitlab::Project.new(current_user).available? + end + + private def action_urls - ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) }.merge(ACTION_DOC_URLS) + LearnGitlab::Onboarding::ACTION_ISSUE_IDS.transform_values { |id| project_issue_url(learn_gitlab_project, id) } + .merge(LearnGitlab::Onboarding::ACTION_DOC_URLS) end def learn_gitlab_project - @learn_gitlab_project ||= LearnGitlab.new(current_user).project + @learn_gitlab_project ||= LearnGitlab::Project.new(current_user).project end def onboarding_progress(project) OnboardingProgress.find_by(namespace: project.namespace) # rubocop: disable CodeReuse/ActiveRecord end - - def experiment_enabled_for_user? - Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_a, subject: current_user) || - Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_b, subject: current_user) - end - - def learn_gitlab_onboarding_available?(project) - OnboardingProgress.onboarding?(project.namespace) && - LearnGitlab.new(current_user).available? - end end diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index ad206d0e5b5..05a55a09271 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -318,4 +318,4 @@ module MarkupHelper extend self end -MarkupHelper.prepend_if_ee('EE::MarkupHelper') +MarkupHelper.prepend_mod_with('MarkupHelper') diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 5dc636ad996..d3db5d24207 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -65,4 +65,14 @@ module MembersHelper 'group and any subresources' end + + def members_pagination_data(members, pagination = {}) + { + current_page: members.respond_to?(:current_page) ? members.current_page : nil, + per_page: members.respond_to?(:limit_value) ? members.limit_value : nil, + total_items: members.respond_to?(:total_count) ? members.total_count : members.count, + param_name: pagination[:param_name] || nil, + params: pagination[:params] || {} + } + end end diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index df7fcb0f3da..514f5fafd65 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -29,16 +29,6 @@ module MergeRequestsHelper classes.join(' ') end - def state_name_with_icon(merge_request) - if merge_request.merged? - [_("Merged"), "git-merge"] - elsif merge_request.closed? - [_("Closed"), "close"] - else - [_("Open"), "issue-open-m"] - end - end - def merge_path_description(merge_request, separator) if merge_request.for_fork? "Project:Branches: #{@merge_request.source_project_path}:#{@merge_request.source_branch} #{separator} #{@merge_request.target_project.full_path}:#{@merge_request.target_branch}" @@ -223,4 +213,4 @@ module MergeRequestsHelper end end -MergeRequestsHelper.prepend_if_ee('EE::MergeRequestsHelper') +MergeRequestsHelper.prepend_mod_with('MergeRequestsHelper') diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb index 9d23ab87b98..3dfd30f07db 100644 --- a/app/helpers/mirror_helper.rb +++ b/app/helpers/mirror_helper.rb @@ -17,4 +17,4 @@ module MirrorHelper end end -MirrorHelper.prepend_if_ee('EE::MirrorHelper') +MirrorHelper.prepend_mod_with('MirrorHelper') diff --git a/app/helpers/namespace_storage_limit_alert_helper.rb b/app/helpers/namespace_storage_limit_alert_helper.rb index d7174c38254..ed11f89a7dd 100644 --- a/app/helpers/namespace_storage_limit_alert_helper.rb +++ b/app/helpers/namespace_storage_limit_alert_helper.rb @@ -6,4 +6,4 @@ module NamespaceStorageLimitAlertHelper end end -NamespaceStorageLimitAlertHelper.prepend_if_ee('EE::NamespaceStorageLimitAlertHelper') +NamespaceStorageLimitAlertHelper.prepend_mod_with('NamespaceStorageLimitAlertHelper') diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb index a4521541bf9..39a8f506ba2 100644 --- a/app/helpers/namespaces_helper.rb +++ b/app/helpers/namespaces_helper.rb @@ -83,6 +83,15 @@ module NamespacesHelper } end + def cascading_namespace_setting_locked?(attribute, group, **args) + return false if group.nil? + + method_name = "#{attribute}_locked?" + return false unless group.namespace_settings.respond_to?(method_name) + + group.namespace_settings.public_send(method_name, **args) # rubocop:disable GitlabSecurity/PublicSend + end + private # Many importers create a temporary Group, so use the real @@ -116,4 +125,4 @@ module NamespacesHelper end end -NamespacesHelper.prepend_if_ee('EE::NamespacesHelper') +NamespacesHelper.prepend_mod_with('NamespacesHelper') diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb new file mode 100644 index 00000000000..159b7ca87f9 --- /dev/null +++ b/app/helpers/nav/top_nav_helper.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +module Nav + module TopNavHelper + PROJECTS_VIEW = :projects + GROUPS_VIEW = :groups + + def top_nav_view_model(project:, group:) + builder = ::Gitlab::Nav::TopNavViewModelBuilder.new + + if current_user + build_view_model(builder: builder, project: project, group: group) + else + build_anonymous_view_model(builder: builder) + end + + builder.build + end + + private + + def build_anonymous_view_model(builder:) + # These come from `app/views/layouts/nav/_explore.html.ham` + if explore_nav_link?(:projects) + builder.add_primary_menu_item( + **projects_menu_item_attrs.merge( + { + active: active_nav_link?(path: %w[dashboard#show root#show projects#trending projects#starred projects#index]), + href: explore_root_path + }) + ) + end + + if explore_nav_link?(:groups) + builder.add_primary_menu_item( + **groups_menu_item_attrs.merge( + { + active: active_nav_link?(controller: [:groups, 'groups/milestones', 'groups/group_members']), + href: explore_groups_path + }) + ) + end + + if explore_nav_link?(:snippets) + builder.add_primary_menu_item( + **snippets_menu_item_attrs.merge( + { + active: active_nav_link?(controller: :snippets), + href: explore_snippets_path + }) + ) + end + end + + def build_view_model(builder:, project:, group:) + # These come from `app/views/layouts/nav/_dashboard.html.haml` + if dashboard_nav_link?(:projects) + current_item = project ? current_project(project: project) : {} + + builder.add_primary_menu_item( + **projects_menu_item_attrs.merge({ + active: active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]), + css_class: 'qa-projects-dropdown', + data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" }, + view: PROJECTS_VIEW + }) + ) + builder.add_view(PROJECTS_VIEW, container_view_props(namespace: 'projects', current_item: current_item, submenu: projects_submenu)) + end + + if dashboard_nav_link?(:groups) + current_item = group ? current_group(group: group) : {} + + builder.add_primary_menu_item( + **groups_menu_item_attrs.merge({ + active: active_nav_link?(path: %w[dashboard/groups explore/groups]), + css_class: 'qa-groups-dropdown', + data: { track_label: "groups_dropdown", track_event: "click_dropdown" }, + view: GROUPS_VIEW + }) + ) + builder.add_view(GROUPS_VIEW, container_view_props(namespace: 'groups', current_item: current_item, submenu: groups_submenu)) + end + + if dashboard_nav_link?(:milestones) + builder.add_primary_menu_item( + id: 'milestones', + title: 'Milestones', + active: active_nav_link?(controller: 'dashboard/milestones'), + icon: 'clock', + data: { qa_selector: 'milestones_link' }, + href: dashboard_milestones_path + ) + end + + if dashboard_nav_link?(:snippets) + builder.add_primary_menu_item( + **snippets_menu_item_attrs.merge({ + active: active_nav_link?(controller: 'dashboard/snippets'), + data: { qa_selector: 'snippets_link' }, + href: dashboard_snippets_path + }) + ) + end + + if dashboard_nav_link?(:activity) + builder.add_primary_menu_item( + id: 'activity', + title: 'Activity', + active: active_nav_link?(path: 'dashboard#activity'), + icon: 'history', + data: { qa_selector: 'activity_link' }, + href: activity_dashboard_path + ) + end + + # Using admin? is generally discouraged because it does not check for + # "admin_mode". In this case we are migrating code and check both, so + # we should be good. + # rubocop: disable Cop/UserAdmin + if current_user&.admin? + builder.add_secondary_menu_item( + id: 'admin', + title: _('Admin'), + active: active_nav_link?(controller: 'admin/dashboard'), + icon: 'admin', + css_class: 'qa-admin-area-link', + href: admin_root_path + ) + end + + if Gitlab::CurrentSettings.admin_mode + if header_link?(:admin_mode) + builder.add_secondary_menu_item( + id: 'leave_admin_mode', + title: _('Leave Admin Mode'), + active: active_nav_link?(controller: 'admin/sessions'), + icon: 'lock-open', + href: destroy_admin_session_path, + method: :post + ) + elsif current_user.admin? + builder.add_secondary_menu_item( + id: 'enter_admin_mode', + title: _('Enter Admin Mode'), + active: active_nav_link?(controller: 'admin/sessions'), + icon: 'lock', + href: new_admin_session_path + ) + end + end + # rubocop: enable Cop/UserAdmin + + if Gitlab::Sherlock.enabled? + builder.add_secondary_menu_item( + id: 'sherlock', + title: _('Sherlock Transactions'), + icon: 'admin', + href: sherlock_transactions_path + ) + end + end + + def projects_menu_item_attrs + { + id: 'project', + title: _('Projects'), + icon: 'project' + } + end + + def groups_menu_item_attrs + { + id: 'groups', + title: 'Groups', + icon: 'group' + } + end + + def snippets_menu_item_attrs + { + id: 'snippets', + title: _('Snippets'), + icon: 'snippet' + } + end + + def container_view_props(namespace:, current_item:, submenu:) + { + namespace: namespace, + currentUserName: current_user&.username, + currentItem: current_item, + linksPrimary: submenu[:primary], + linksSecondary: submenu[:secondary] + } + end + + def current_project(project:) + return {} unless project.persisted? + + { + id: project.id, + name: project.name, + namespace: project.full_name, + webUrl: project_path(project), + avatarUrl: project.avatar_url + } + end + + def current_group(group:) + return {} unless group.persisted? + + { + id: group.id, + name: group.name, + namespace: group.full_name, + webUrl: group_path(group), + avatarUrl: group.avatar_url + } + end + + def projects_submenu + # These project links come from `app/views/layouts/nav/projects_dropdown/_show.html.haml` + builder = ::Gitlab::Nav::TopNavMenuBuilder.new + builder.add_primary_menu_item(id: 'your', title: _('Your projects'), href: dashboard_projects_path) + builder.add_primary_menu_item(id: 'starred', title: _('Starred projects'), href: starred_dashboard_projects_path) + builder.add_primary_menu_item(id: 'explore', title: _('Explore projects'), href: explore_root_path) + builder.add_secondary_menu_item(id: 'create', title: _('Create new project'), href: new_project_path) + builder.build + end + + def groups_submenu + # These group links come from `app/views/layouts/nav/groups_dropdown/_show.html.haml` + builder = ::Gitlab::Nav::TopNavMenuBuilder.new + builder.add_primary_menu_item(id: 'your', title: _('Your groups'), href: dashboard_groups_path) + builder.add_primary_menu_item(id: 'explore', title: _('Explore groups'), href: explore_groups_path) + builder.add_secondary_menu_item(id: 'create', title: _('Create group'), href: new_group_path(anchor: 'create-group-pane')) + builder.build + end + end +end + +Nav::TopNavHelper.prepend_mod diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index db144f63f92..aab1a44bdfb 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -12,6 +12,7 @@ module NavHelper def page_with_sidebar_class class_name = page_gutter_class class_name << 'page-with-contextual-sidebar' if defined?(@left_sidebar) && @left_sidebar + class_name << 'sidebar-refactoring' if Feature.enabled?(:sidebar_refactor, current_user) class_name << 'page-with-icon-sidebar' if collapsed_sidebar? && @left_sidebar class_name -= ['right-sidebar-expanded'] if defined?(@right_sidebar) && !@right_sidebar @@ -68,7 +69,14 @@ module NavHelper end def group_issues_sub_menu_items - %w(groups#issues labels#index milestones#index boards#index boards#show) + %w[ + groups#issues + milestones#index + boards#index + boards#show + ].tap do |paths| + paths << 'labels#index' if Feature.disabled?(:sidebar_refactor, current_user, default_enabled: :yaml) + end end private @@ -100,4 +108,4 @@ module NavHelper end end -NavHelper.prepend_if_ee('EE::NavHelper') +NavHelper.prepend_mod_with('NavHelper') diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 62580124c0f..fff7e5d1c7f 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -215,4 +215,4 @@ module NotesHelper end end -NotesHelper.prepend_if_ee('EE::NotesHelper') +NotesHelper.prepend_mod_with('NotesHelper') diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb index 03da679cfdd..38c98776fdf 100644 --- a/app/helpers/notify_helper.rb +++ b/app/helpers/notify_helper.rb @@ -18,7 +18,7 @@ module NotifyHelper when "Developer" s_("InviteEmail|As a developer, you have full access to projects, so you can take an idea from concept to production.") when "Maintainer" - s_("InviteEmail|As a maintainer, you have full access to projects. You can push commits to master and deploy to production.") + s_("InviteEmail|As a maintainer, you have full access to projects. You can push commits to the default branch and deploy to production.") when "Owner" s_("InviteEmail|As an owner, you have full access to projects and can manage access to the group, including inviting new members.") when "Minimal Access" diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb index 51f4304911b..df07baa2d03 100644 --- a/app/helpers/operations_helper.rb +++ b/app/helpers/operations_helper.rb @@ -44,4 +44,4 @@ module OperationsHelper end end -OperationsHelper.prepend_if_ee('EE::OperationsHelper') +OperationsHelper.prepend_mod_with('OperationsHelper') diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb index 6997c8cffda..2729951d685 100644 --- a/app/helpers/page_layout_helper.rb +++ b/app/helpers/page_layout_helper.rb @@ -162,7 +162,6 @@ module PageLayoutHelper default_properties = { current_emoji: '', current_message: '', - can_set_user_availability: Feature.enabled?(:set_user_availability_status, user, default_enabled: :yaml), default_emoji: UserStatus::DEFAULT_EMOJI } diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index add6e1eaf6f..d851ed3db8f 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -4,8 +4,8 @@ module PreferencesHelper def layout_choices [ - ['Fixed', :fixed], - ['Fluid', :fluid] + ['Fixed', :fixed], + ['Fluid', :fluid] ] end @@ -76,7 +76,7 @@ module PreferencesHelper def language_choices options_for_select( - Gitlab::I18n.selectable_locales.map(&:reverse).sort, + selectable_locales_with_translation_level.sort, current_user.preferred_language ) end @@ -107,6 +107,18 @@ module PreferencesHelper def default_first_day_of_week first_day_of_week_choices.rassoc(Gitlab::CurrentSettings.first_day_of_week).first end + + def selectable_locales_with_translation_level + Gitlab::I18n.selectable_locales.map do |code, language| + [ + s_("i18n|%{language} (%{percent_translated}%% translated)") % { + language: language, + percent_translated: Gitlab::I18n.percentage_translated_for(code) + }, + code + ] + end + end end -PreferencesHelper.prepend_if_ee('EE::PreferencesHelper') +PreferencesHelper.prepend_mod_with('PreferencesHelper') diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb index 3219620de71..f6ed567c9ea 100644 --- a/app/helpers/profiles_helper.rb +++ b/app/helpers/profiles_helper.rb @@ -51,4 +51,4 @@ module ProfilesHelper end end -ProfilesHelper.prepend_ee_mod +ProfilesHelper.prepend_mod diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb index b705258f133..b46e3eb3bc3 100644 --- a/app/helpers/projects/alert_management_helper.rb +++ b/app/helpers/projects/alert_management_helper.rb @@ -10,6 +10,7 @@ module Projects::AlertManagementHelper 'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'), 'user-can-enable-alert-management' => can?(current_user, :admin_operations, project).to_s, 'alert-management-enabled' => alert_management_enabled?(project).to_s, + 'has-managed-prometheus' => has_managed_prometheus?(project).to_s, 'text-query': params[:search], 'assignee-username-query': params[:assignee_username] } @@ -27,6 +28,10 @@ module Projects::AlertManagementHelper private + def has_managed_prometheus?(project) + project.prometheus_service&.prometheus_available? == true + end + def alert_management_enabled?(project) !!( project.alert_management_alerts.any? || diff --git a/app/helpers/projects/incidents_helper.rb b/app/helpers/projects/incidents_helper.rb index 63504cb55b9..dde2980817f 100644 --- a/app/helpers/projects/incidents_helper.rb +++ b/app/helpers/projects/incidents_helper.rb @@ -16,4 +16,4 @@ module Projects::IncidentsHelper end end -Projects::IncidentsHelper.prepend_if_ee('EE::Projects::IncidentsHelper') +Projects::IncidentsHelper.prepend_mod_with('Projects::IncidentsHelper') diff --git a/app/helpers/projects/project_members_helper.rb b/app/helpers/projects/project_members_helper.rb index 662afbcfd25..fa68bbad135 100644 --- a/app/helpers/projects/project_members_helper.rb +++ b/app/helpers/projects/project_members_helper.rb @@ -27,29 +27,41 @@ module Projects::ProjectMembersHelper project.group.has_owner?(current_user) end - def project_group_links_data_json(group_links) - GroupLink::ProjectGroupLinkSerializer.new.represent(group_links, { current_user: current_user }).to_json + def project_members_list_data_json(project, members, pagination = {}) + project_members_list_data(project, members, pagination).to_json end - def project_members_data_json(project, members) - MemberSerializer.new.represent(members, { current_user: current_user, group: project.group, source: project }).to_json + def project_group_links_list_data_json(project, group_links) + project_group_links_list_data(project, group_links).to_json end - def project_members_list_data_attributes(project, members) + private + + def project_members_serialized(project, members) + MemberSerializer.new.represent(members, { current_user: current_user, group: project.group, source: project }) + end + + def project_group_links_serialized(group_links) + GroupLink::ProjectGroupLinkSerializer.new.represent(group_links, { current_user: current_user }) + end + + def project_members_list_data(project, members, pagination) { - members: project_members_data_json(project, members), + members: project_members_serialized(project, members), + pagination: members_pagination_data(members, pagination), member_path: project_project_member_path(project, ':id'), source_id: project.id, - can_manage_members: can_manage_project_members?(project).to_s + can_manage_members: can_manage_project_members?(project) } end - def project_group_links_list_data_attributes(project, group_links) + def project_group_links_list_data(project, group_links) { - members: project_group_links_data_json(group_links), + members: project_group_links_serialized(group_links), + pagination: members_pagination_data(group_links), member_path: project_group_link_path(project, ':id'), source_id: project.id, - can_manage_members: can_manage_project_members?(project).to_s + can_manage_members: can_manage_project_members?(project) } end end diff --git a/app/helpers/projects/security/configuration_helper.rb b/app/helpers/projects/security/configuration_helper.rb index 265d46cbc41..dee106ab3ae 100644 --- a/app/helpers/projects/security/configuration_helper.rb +++ b/app/helpers/projects/security/configuration_helper.rb @@ -10,4 +10,4 @@ module Projects end end -::Projects::Security::ConfigurationHelper.prepend_if_ee('::EE::Projects::Security::ConfigurationHelper') +::Projects::Security::ConfigurationHelper.prepend_mod_with('Projects::Security::ConfigurationHelper') diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 4be6cd4276b..f2a50ce1325 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -125,34 +125,12 @@ module ProjectsHelper project.fork_source if project.fork_source && can?(current_user, :read_project, project.fork_source) end - def project_nav_tabs - @nav_tabs ||= get_project_nav_tabs(@project, current_user) - end - def project_search_tabs?(tab) abilities = Array(search_tab_ability_map[tab]) abilities.any? { |ability| can?(current_user, ability, @project) } end - def project_nav_tab?(name) - project_nav_tabs.include? name - end - - def any_project_nav_tab?(tabs) - tabs.any? { |tab| project_nav_tab?(tab) } - end - - def project_for_deploy_key(deploy_key) - if deploy_key.has_access_to?(@project) - @project - else - deploy_key.projects.find do |project| - can?(current_user, :read_project, project) - end - end - end - def can_change_visibility_level?(project, current_user) can?(current_user, :change_visibility_level, project) end @@ -285,10 +263,6 @@ module ProjectsHelper !disabled && !compact_mode end - def settings_operations_available? - !@project.archived? && can?(current_user, :admin_operations, @project) - end - def error_tracking_setting_project_json setting = @project.error_tracking_setting @@ -378,89 +352,6 @@ module ProjectsHelper private - def can_read_security_configuration?(project, current_user) - can?(current_user, :access_security_and_compliance, project) && - can?(current_user, :read_security_configuration, project) - end - - def get_project_security_nav_tabs(project, current_user) - if can_read_security_configuration?(project, current_user) - [:security_and_compliance, :security_configuration] - else - [] - end - end - - # rubocop:disable Metrics/CyclomaticComplexity - def get_project_nav_tabs(project, current_user) - nav_tabs = [:home] - - unless project.empty_repo? - nav_tabs += [:files, :commits, :network, :graphs, :forks] if can?(current_user, :download_code, project) - nav_tabs << :releases if can?(current_user, :read_release, project) - end - - nav_tabs += get_project_security_nav_tabs(project, current_user) - - if project.repo_exists? && can?(current_user, :read_merge_request, project) - nav_tabs << :merge_requests - end - - if Gitlab.config.registry.enabled && can?(current_user, :read_container_image, project) - nav_tabs << :container_registry - end - - if Feature.enabled?(:infrastructure_registry_page) - nav_tabs << :infrastructure_registry - end - - # Pipelines feature is tied to presence of builds - if can?(current_user, :read_build, project) - nav_tabs << :pipelines - end - - if can_view_operations_tab?(current_user, project) - nav_tabs << :operations - end - - if can_view_product_analytics?(current_user, project) - nav_tabs << :product_analytics - end - - tab_ability_map.each do |tab, ability| - if can?(current_user, ability, project) - nav_tabs << tab - end - end - - apply_external_nav_tabs(nav_tabs, project) - - nav_tabs += package_nav_tabs(project, current_user) - - nav_tabs << :learn_gitlab if learn_gitlab_experiment_enabled?(project) - - nav_tabs - end - # rubocop:enable Metrics/CyclomaticComplexity - - def package_nav_tabs(project, current_user) - [].tap do |tabs| - if ::Gitlab.config.packages.enabled && can?(current_user, :read_package, project) - tabs << :packages - end - end - end - - def apply_external_nav_tabs(nav_tabs, project) - nav_tabs << :external_issue_tracker if project.external_issue_tracker - nav_tabs << :external_wiki if project.external_wiki - - if project.has_confluence? - nav_tabs.delete(:wiki) - nav_tabs << :confluence - end - end - def tab_ability_map { cycle_analytics: :read_cycle_analytics, @@ -485,32 +376,6 @@ module ProjectsHelper } end - def view_operations_tab_ability - [ - :metrics_dashboard, - :read_alert_management_alert, - :read_environment, - :read_issue, - :read_sentry_issue, - :read_cluster, - :read_feature_flag, - :read_terraform_state - ] - end - - def can_view_operations_tab?(current_user, project) - return false unless project.feature_available?(:operations, current_user) - - view_operations_tab_ability.any? do |ability| - can?(current_user, ability, project) - end - end - - def can_view_product_analytics?(current_user, project) - Feature.enabled?(:product_analytics, project) && - can?(current_user, :read_product_analytics, project) - end - def search_tab_ability_map @search_tab_ability_map ||= tab_ability_map.merge( blobs: :download_code, @@ -578,14 +443,6 @@ module ProjectsHelper end end - def sidebar_operations_link_path(project = @project) - if can?(current_user, :read_environment, project) - metrics_project_environments_path(project) - else - project_feature_flags_path(project) - end - end - def project_last_activity(project) if project.last_activity_at time_ago_with_tooltip(project.last_activity_at, placement: 'bottom', html_class: 'last_activity_time_ago') @@ -723,29 +580,6 @@ module ProjectsHelper "#{request.path}?#{options.to_param}" end - def sidebar_security_configuration_paths - %w[ - projects/security/configuration#show - ] - end - - def sidebar_settings_paths - %w[ - projects#edit - integrations#show - services#edit - hooks#index - hooks#edit - access_tokens#index - hook_logs#show - repository#show - ci_cd#show - operations#show - badges#index - pages#show - ] - end - def sidebar_operations_paths %w[ environments @@ -766,10 +600,6 @@ module ProjectsHelper ] end - def sidebar_security_paths - %w[projects/security/configuration#show] - end - def user_can_see_auto_devops_implicitly_enabled_banner?(project, user) Ability.allowed?(user, :admin_project, project) && project.has_auto_devops_implicitly_enabled? && @@ -782,6 +612,16 @@ module ProjectsHelper end def settings_container_registry_expiration_policy_available?(project) + Feature.disabled?(:sidebar_refactor, current_user) && + can_destroy_container_registry_image?(current_user, project) + end + + def settings_packages_and_registries_enabled?(project) + Feature.enabled?(:sidebar_refactor, current_user) && + can_destroy_container_registry_image?(current_user, project) + end + + def can_destroy_container_registry_image?(current_user, project) Gitlab.config.registry.enabled && can?(current_user, :destroy_container_image, project) end @@ -811,4 +651,4 @@ module ProjectsHelper end end -ProjectsHelper.prepend_if_ee('EE::ProjectsHelper') +ProjectsHelper.prepend_mod_with('ProjectsHelper') diff --git a/app/helpers/registrations_helper.rb b/app/helpers/registrations_helper.rb new file mode 100644 index 00000000000..79f0a66f995 --- /dev/null +++ b/app/helpers/registrations_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module RegistrationsHelper + def social_signin_enabled? + ::Gitlab.dev_env_or_com? && + omniauth_enabled? && + devise_mapping.omniauthable? && + button_based_providers_enabled? + end +end + +RegistrationsHelper.prepend_mod_with('RegistrationsHelper') diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index d9851564585..de9288121c4 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -72,4 +72,4 @@ module ReleasesHelper end end -ReleasesHelper.prepend_if_ee('EE::ReleasesHelper') +ReleasesHelper.prepend_mod_with('ReleasesHelper') diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 2568917bafc..1f4c98d6f28 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -310,7 +310,7 @@ module SearchHelper link_to search_path(search_params) do concat label concat ' ' - concat content_tag(:span, count, class: ['badge badge-pill', badge_class], data: badge_data) + concat content_tag(:span, count, class: ['badge badge-pill gl-badge badge-muted sm', badge_class], data: badge_data) end end end @@ -431,4 +431,4 @@ module SearchHelper end end -SearchHelper.prepend_if_ee('EE::SearchHelper') +SearchHelper.prepend_mod_with('SearchHelper') diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index 4d0f9e530fb..88aff31af54 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -91,4 +91,4 @@ module SelectsHelper end end -SelectsHelper.prepend_if_ee('EE::SelectsHelper') +SelectsHelper.prepend_mod_with('SelectsHelper') diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index ffa09cb12fb..3d3ab3a6972 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -153,9 +153,9 @@ module ServicesHelper private def integration_level(integration) - if integration.instance + if integration.instance_level? 'instance' - elsif integration.group_id + elsif integration.group_level? 'group' else 'project' @@ -172,10 +172,14 @@ module ServicesHelper name: integration.to_param } end + + def show_service_templates_nav_link? + Feature.disabled?(:disable_service_templates, type: :development, default_enabled: :yaml) + end end -ServicesHelper.prepend_if_ee('EE::ServicesHelper') +ServicesHelper.prepend_mod_with('ServicesHelper') # The methods in `EE::ServicesHelper` should be available as both instance and # class methods. -ServicesHelper.extend_if_ee('EE::ServicesHelper') +ServicesHelper.extend_mod_with('ServicesHelper') diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 31dfe21671a..0fc306a3f2e 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -39,7 +39,13 @@ module SidebarsHelper current_user: user, container: project, learn_gitlab_experiment_enabled: learn_gitlab_experiment_enabled?(project), - current_ref: current_ref + learn_gitlab_experiment_tracking_category: learn_gitlab_experiment_tracking_category, + current_ref: current_ref, + jira_issues_integration: project_jira_issues_integration?, + can_view_pipeline_editor: can_view_pipeline_editor?(project), + show_cluster_hint: show_gke_cluster_integration_callout?(project) } end end + +SidebarsHelper.prepend_mod_with('SidebarsHelper') diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index f4af7a5a350..84eb0405c01 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -72,4 +72,10 @@ module SnippetsHelper concat(file_count) end end + + def project_snippets_award_api_path(snippet) + if Feature.enabled?(:improved_emoji_picker, snippet.project, default_enabled: :yaml) + api_v4_projects_snippets_award_emoji_path(id: snippet.project.id, snippet_id: snippet.id) + end + end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 974ec046bbb..0bb9e9e9bdd 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -301,4 +301,4 @@ module SortingHelper end end -SortingHelper.prepend_if_ee('::EE::SortingHelper') +SortingHelper.prepend_mod_with('SortingHelper') diff --git a/app/helpers/sorting_titles_values_helper.rb b/app/helpers/sorting_titles_values_helper.rb index 651a6437479..28d70f1db45 100644 --- a/app/helpers/sorting_titles_values_helper.rb +++ b/app/helpers/sorting_titles_values_helper.rb @@ -328,4 +328,4 @@ module SortingTitlesValuesHelper end end -SortingHelper.include_if_ee('::EE::SortingTitlesValuesHelper') +SortingHelper.include_mod_with('SortingTitlesValuesHelper') diff --git a/app/helpers/ssh_keys_helper.rb b/app/helpers/ssh_keys_helper.rb index 381db893943..f5a9bea482b 100644 --- a/app/helpers/ssh_keys_helper.rb +++ b/app/helpers/ssh_keys_helper.rb @@ -12,7 +12,10 @@ module SshKeysHelper message: _('This action cannot be undone, and will permanently delete the %{key} SSH key') % { key: key.title }, okVariant: 'danger', okTitle: _('Delete') - } + }, + toggle: 'tooltip', + placement: 'top', + container: 'body' } end end diff --git a/app/helpers/subscribable_banner_helper.rb b/app/helpers/subscribable_banner_helper.rb index c9d4370f8ad..d9251fb3f21 100644 --- a/app/helpers/subscribable_banner_helper.rb +++ b/app/helpers/subscribable_banner_helper.rb @@ -6,4 +6,4 @@ module SubscribableBannerHelper end end -SubscribableBannerHelper.prepend_if_ee('EE::SubscribableBannerHelper') +SubscribableBannerHelper.prepend_mod_with('SubscribableBannerHelper') diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 85e644967ea..521423fbb94 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -54,8 +54,8 @@ module SystemNoteHelper extend self end -SystemNoteHelper.prepend_if_ee('EE::SystemNoteHelper') +SystemNoteHelper.prepend_mod_with('SystemNoteHelper') # The methods in `EE::SystemNoteHelper` should be available as both instance and # class methods. -SystemNoteHelper.extend_if_ee('EE::SystemNoteHelper') +SystemNoteHelper.extend_mod_with('SystemNoteHelper') diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb index 00f65b72c8e..fe045182c96 100644 --- a/app/helpers/time_zone_helper.rb +++ b/app/helpers/time_zone_helper.rb @@ -18,7 +18,7 @@ module TimeZoneHelper def timezone_data(format: :short) attrs = TIME_ZONE_FORMAT_ATTRS.fetch(format) do valid_formats = TIME_ZONE_FORMAT_ATTRS.keys.map { |k| ":#{k}"}.join(", ") - raise ArgumentError.new("Invalid format :#{format}. Valid formats are #{valid_formats}.") + raise ArgumentError, "Invalid format :#{format}. Valid formats are #{valid_formats}." end ActiveSupport::TimeZone.all.map do |timezone| diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb index e034a985b50..0993e210f42 100644 --- a/app/helpers/timeboxes_helper.rb +++ b/app/helpers/timeboxes_helper.rb @@ -288,4 +288,4 @@ module TimeboxesHelper end end -TimeboxesHelper.prepend_if_ee('EE::TimeboxesHelper') +TimeboxesHelper.prepend_mod_with('TimeboxesHelper') diff --git a/app/helpers/timeboxes_routing_helper.rb b/app/helpers/timeboxes_routing_helper.rb index 6fb5a1a3185..6a5bef74dc9 100644 --- a/app/helpers/timeboxes_routing_helper.rb +++ b/app/helpers/timeboxes_routing_helper.rb @@ -18,4 +18,4 @@ module TimeboxesRoutingHelper end end -TimeboxesRoutingHelper.prepend_if_ee('EE::TimeboxesRoutingHelper') +TimeboxesRoutingHelper.prepend_mod_with('TimeboxesRoutingHelper') diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index e9a0fef06c4..e9dc271dbdd 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -110,10 +110,8 @@ module TodosHelper 'alert' end - content_tag(:span, nil, class: 'target-status') do - content_tag(:span, nil, class: "status-box status-box-#{type}-#{todo.target.state.to_s.dasherize}") do - todo.target.state.to_s.capitalize - end + tag.span class: "gl-my-0 gl-px-2 status-box status-box-#{type}-#{todo.target.state.to_s.dasherize}" do + todo.target.state.to_s.capitalize end end @@ -232,4 +230,4 @@ module TodosHelper end end -TodosHelper.prepend_if_ee('EE::TodosHelper') +TodosHelper.prepend_mod_with('TodosHelper') diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index b795851ba30..54c03d3d966 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -205,4 +205,4 @@ module TreeHelper end end -TreeHelper.prepend_if_ee('::EE::TreeHelper') +TreeHelper.prepend_mod_with('TreeHelper') diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 7a90984cd77..23db3be631c 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -44,7 +44,7 @@ module UserCalloutsHelper def show_service_templates_deprecated_callout? !Gitlab.com? && current_user&.admin? && - Service.for_template.active.exists? && + Integration.for_template.active.exists? && !user_dismissed?(SERVICE_TEMPLATES_DEPRECATED_CALLOUT) end @@ -80,4 +80,4 @@ module UserCalloutsHelper end end -UserCalloutsHelper.prepend_if_ee('EE::UserCalloutsHelper') +UserCalloutsHelper.prepend_mod_with('UserCalloutsHelper') diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 1979426f844..c1d05c2d3cf 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -100,7 +100,7 @@ module UsersHelper badges << blocked_user_badge(user) if user.blocked? badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin? badges << { text: s_('AdminUsers|External'), variant: 'secondary' } if user.external? - badges << { text: s_("AdminUsers|It's you!"), variant: nil } if current_user == user + badges << { text: s_("AdminUsers|It's you!"), variant: 'muted' } if current_user == user end end @@ -162,6 +162,49 @@ module UsersHelper header + list end + def user_ban_data(user) + { + path: ban_admin_user_path(user), + method: 'put', + modal_attributes: { + title: s_('AdminUsers|Ban user %{username}?') % { username: sanitize_name(user.name) }, + message: s_('AdminUsers|You can unban their account in the future. Their data remains intact.'), + okVariant: 'warning', + okTitle: s_('AdminUsers|Ban') + }.to_json + } + end + + def user_unban_data(user) + { + path: unban_admin_user_path(user), + method: 'put', + modal_attributes: { + title: s_('AdminUsers|Unban %{username}?') % { username: sanitize_name(user.name) }, + message: s_('AdminUsers|You ban their account in the future if necessary.'), + okVariant: 'info', + okTitle: s_('AdminUsers|Unban') + }.to_json + } + end + + def user_ban_effects + header = tag.p s_('AdminUsers|Banning the user has the following effects:') + + list = tag.ul do + concat tag.li s_('AdminUsers|User will be blocked') + end + + link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path("user/admin_area/moderate_users", anchor: "ban-a-user") } + info = tag.p s_('AdminUsers|Learn more about %{link_start}banned users.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + + header + list + info + end + + def ban_feature_available? + Feature.enabled?(:ban_user_feature_flag) + end + def user_deactivation_data(user, message) { path: deactivate_admin_user_path(user), @@ -235,6 +278,9 @@ module UsersHelper pending_approval_badge = { text: s_('AdminUsers|Pending approval'), variant: 'info' } return pending_approval_badge if user.blocked_pending_approval? + banned_badge = { text: s_('AdminUsers|Banned'), variant: 'danger' } + return banned_badge if user.banned? + { text: s_('AdminUsers|Blocked'), variant: 'danger' } end @@ -322,4 +368,4 @@ module UsersHelper end end -UsersHelper.prepend_if_ee('EE::UsersHelper') +UsersHelper.prepend_mod_with('UsersHelper') diff --git a/app/helpers/version_check_helper.rb b/app/helpers/version_check_helper.rb index bac3c99e3e5..6f94c241914 100644 --- a/app/helpers/version_check_helper.rb +++ b/app/helpers/version_check_helper.rb @@ -11,16 +11,24 @@ module VersionCheckHelper def link_to_version if Gitlab.pre_release? - commit_link = link_to(Gitlab.revision, Gitlab::COM_URL + namespace_project_commits_path('gitlab-org', source_code_project, Gitlab.revision)) + commit_link = link_to(Gitlab.revision, source_host_url + namespace_project_commits_path(source_code_group, source_code_project, Gitlab.revision)) [Gitlab::VERSION, content_tag(:small, commit_link)].join(' ').html_safe else - link_to Gitlab::VERSION, Gitlab::COM_URL + namespace_project_tag_path('gitlab-org', source_code_project, "v#{Gitlab::VERSION}") + link_to Gitlab::VERSION, source_host_url + namespace_project_tag_path(source_code_group, source_code_project, "v#{Gitlab::VERSION}") end end + def source_host_url + Gitlab::COM_URL + end + + def source_code_group + 'gitlab-org' + end + def source_code_project 'gitlab-foss' end end -VersionCheckHelper.prepend_if_ee('EE::VersionCheckHelper') +VersionCheckHelper.prepend_mod diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb index 170e3c45a21..0d27e07f172 100644 --- a/app/helpers/webpack_helper.rb +++ b/app/helpers/webpack_helper.rb @@ -1,10 +1,32 @@ # frozen_string_literal: true module WebpackHelper + def prefetch_link_tag(source) + href = asset_path(source) + + link_tag = tag.link(rel: 'prefetch', href: href) + + early_hints_link = "<#{href}>; rel=prefetch" + + request.send_early_hints("Link" => early_hints_link) + + link_tag + end + def webpack_bundle_tag(bundle) javascript_include_tag(*webpack_entrypoint_paths(bundle)) end + def webpack_preload_asset_tag(asset, options = {}) + path = Gitlab::Webpack::Manifest.asset_paths(asset).first + + if options.delete(:prefetch) + prefetch_link_tag(path) + else + preload_link_tag(path, options) + end + end + def webpack_controller_bundle_tags chunks = [] diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb index 9362ae1491f..5fca00c5dce 100644 --- a/app/helpers/whats_new_helper.rb +++ b/app/helpers/whats_new_helper.rb @@ -10,6 +10,33 @@ module WhatsNewHelper end def display_whats_new? - Gitlab.dev_env_org_or_com? || user_signed_in? + (Gitlab.dev_env_org_or_com? || user_signed_in?) && + !Gitlab::CurrentSettings.current_application_settings.whats_new_variant_disabled? + end + + def whats_new_variants + ApplicationSetting.whats_new_variants + end + + def whats_new_variants_label(variant) + case variant + when 'all_tiers' + _("Enable What's new: All tiers") + when 'current_tier' + _("Enable What's new: Current tier only") + when 'disabled' + _("Disable What's new") + end + end + + def whats_new_variants_description(variant) + case variant + when 'all_tiers' + _("What's new presents new features from all tiers to help you keep track of all new features.") + when 'current_tier' + _("What's new presents new features for your current subscription tier, while hiding new features not available to your subscription tier.") + when 'disabled' + _("What's new is disabled and can no longer be viewed.") + end end end diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index c2a5ff40852..1b0d1254dc8 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -136,4 +136,4 @@ module WikiHelper end end -WikiHelper.prepend_if_ee('EE::WikiHelper') +WikiHelper.prepend_mod_with('WikiHelper') diff --git a/app/helpers/x509_helper.rb b/app/helpers/x509_helper.rb index 009635fb629..4afc5643af4 100644 --- a/app/helpers/x509_helper.rb +++ b/app/helpers/x509_helper.rb @@ -13,7 +13,7 @@ module X509Helper end subjects - rescue + rescue StandardError {} end diff --git a/app/mailers/emails/in_product_marketing.rb b/app/mailers/emails/in_product_marketing.rb index d21c3d13b10..97243660512 100644 --- a/app/mailers/emails/in_product_marketing.rb +++ b/app/mailers/emails/in_product_marketing.rb @@ -2,8 +2,6 @@ module Emails module InProductMarketing - include InProductMarketingHelper - FROM_ADDRESS = 'GitLab <team@gitlab.com>' CUSTOM_HEADERS = { from: FROM_ADDRESS, @@ -15,13 +13,11 @@ module Emails }.freeze def in_product_marketing_email(recipient_id, group_id, track, series) - @track = track - @series = series - @group = Group.find(group_id) + group = Group.find(group_id) + email = User.find(recipient_id).notification_email_for(group) + @message = Gitlab::Email::Message::InProductMarketing.for(track).new(group: group, series: series) - email = User.find(recipient_id).notification_email_for(@group) - subject = subject_line(track, series) - mail_to(to: email, subject: subject) + mail_to(to: email, subject: @message.subject_line) end private @@ -29,8 +25,17 @@ module Emails def mail_to(to:, subject:) custom_headers = Gitlab.com? ? CUSTOM_HEADERS : {} mail(to: to, subject: subject, **custom_headers) do |format| - format.html { render layout: nil } - format.text { render layout: nil } + format.html do + @message.format = :html + + render layout: nil + end + + format.text do + @message.format = :text + + render layout: nil + end end end end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index c565df1a2ee..51c4779d8cf 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -139,4 +139,4 @@ module Emails end end -Emails::Issues.prepend_if_ee('EE::Emails::Issues') +Emails::Issues.prepend_mod_with('Emails::Issues') diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index f4d3676dc5c..674a9bfc4eb 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -167,4 +167,4 @@ module Emails end end -Emails::Members.prepend_if_ee('EE::Emails::Members') +Emails::Members.prepend_mod_with('Emails::Members') diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index e538b5e4718..2746b8b7188 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -167,4 +167,4 @@ module Emails end end -Emails::MergeRequests.prepend_if_ee('EE::Emails::MergeRequests') +Emails::MergeRequests.prepend_mod_with('Emails::MergeRequests') diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb index 4b56ff60f09..587c1479286 100644 --- a/app/mailers/emails/notes.rb +++ b/app/mailers/emails/notes.rb @@ -84,4 +84,4 @@ module Emails end end -Emails::Notes.prepend_if_ee('EE::Emails::Notes') +Emails::Notes.prepend_mod_with('Emails::Notes') diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index f967323f849..2efcba54c13 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -132,4 +132,4 @@ module Emails end end -Emails::Profile.prepend_if_ee('EE::Emails::Profile') +Emails::Profile.prepend_mod_with('Emails::Profile') diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index a4b7b140169..2ae82b49609 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -66,4 +66,4 @@ module Emails end end -Emails::Projects.prepend_if_ee('EE::Emails::Projects') +Emails::Projects.prepend_mod_with('Emails::Projects') diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 5f5afef350b..dd75ab4bf03 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -223,4 +223,4 @@ class Notify < ApplicationMailer end end -Notify.prepend_if_ee('EE::Notify') +Notify.prepend_mod_with('Notify') diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 5fda60a7408..df0d1774d6b 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -88,6 +88,10 @@ class NotifyPreview < ActionMailer::Preview Notify.issues_csv_email(user, project, '1997,Ford,E350', { truncated: false, rows_expected: 3, rows_written: 3 }).message end + def new_issue_email + Notify.new_issue_email(user.id, issue.id).message + end + def new_merge_request_email Notify.new_merge_request_email(user.id, merge_request.id).message end @@ -200,7 +204,7 @@ class NotifyPreview < ActionMailer::Preview end def issue - @merge_request ||= project.issues.first + @issue ||= project.issues.first end def merge_request @@ -251,4 +255,4 @@ class NotifyPreview < ActionMailer::Preview end end -NotifyPreview.prepend_if_ee('EE::Preview::NotifyPreview') +NotifyPreview.prepend_mod_with('Preview::NotifyPreview') diff --git a/app/models/ability.rb b/app/models/ability.rb index ba46a98b951..c18bd21d754 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_dependency 'declarative_policy' - class Ability class << self # Given a list of users and a project this method returns the users that can diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb index 7090d9f4ea1..156111ffaf3 100644 --- a/app/models/alert_management/alert.rb +++ b/app/models/alert_management/alert.rb @@ -20,7 +20,13 @@ module AlertManagement resolved: 2, ignored: 3 }.freeze - private_constant :STATUSES + + STATUS_DESCRIPTIONS = { + triggered: 'Investigation has not started', + acknowledged: 'Someone is actively investigating the problem', + resolved: 'No further work is required', + ignored: 'No action will be taken on the alert' + }.freeze belongs_to :project belongs_to :issue, optional: true @@ -271,4 +277,4 @@ module AlertManagement end end -AlertManagement::Alert.prepend_if_ee('EE::AlertManagement::Alert') +AlertManagement::Alert.prepend_mod_with('AlertManagement::Alert') diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb index e98c770c364..2caa9a18445 100644 --- a/app/models/alert_management/http_integration.rb +++ b/app/models/alert_management/http_integration.rb @@ -10,7 +10,7 @@ module AlertManagement attr_encrypted :token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' default_value_for(:endpoint_identifier, allows_nil: false) { SecureRandom.hex(8) } diff --git a/app/models/alerting/project_alerting_setting.rb b/app/models/alerting/project_alerting_setting.rb index 8f8c38f11e4..34fa27eb29b 100644 --- a/app/models/alerting/project_alerting_setting.rb +++ b/app/models/alerting/project_alerting_setting.rb @@ -10,7 +10,7 @@ module Alerting attr_encrypted :token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' before_validation :ensure_token diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb index b2c16444a2a..e8b03fa066a 100644 --- a/app/models/analytics/cycle_analytics/project_stage.rb +++ b/app/models/analytics/cycle_analytics/project_stage.rb @@ -7,10 +7,13 @@ module Analytics validates :project, presence: true belongs_to :project + belongs_to :value_stream, class_name: 'Analytics::CycleAnalytics::ProjectValueStream', foreign_key: :project_value_stream_id alias_attribute :parent, :project alias_attribute :parent_id, :project_id + alias_attribute :value_stream_id, :project_value_stream_id + delegate :group, to: :project validate :validate_project_group_for_label_events, if: -> { start_event_label_based? || end_event_label_based? } diff --git a/app/models/analytics/cycle_analytics/project_value_stream.rb b/app/models/analytics/cycle_analytics/project_value_stream.rb new file mode 100644 index 00000000000..3eba7e87b17 --- /dev/null +++ b/app/models/analytics/cycle_analytics/project_value_stream.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Analytics::CycleAnalytics::ProjectValueStream < ApplicationRecord + belongs_to :project + + has_many :stages, class_name: 'Analytics::CycleAnalytics::ProjectStage' + + validates :project, :name, presence: true + validates :name, length: { minimum: 3, maximum: 100, allow_nil: false }, uniqueness: { scope: :project_id } + + def custom? + false + end + + def stages + [] + end + + def self.build_default_value_stream(project) + new(name: Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME, project: project) + end +end diff --git a/app/models/analytics/usage_trends/measurement.rb b/app/models/analytics/usage_trends/measurement.rb index ad0272699c2..46c5d56d210 100644 --- a/app/models/analytics/usage_trends/measurement.rb +++ b/app/models/analytics/usage_trends/measurement.rb @@ -58,4 +58,4 @@ module Analytics end end -Analytics::UsageTrends::Measurement.prepend_if_ee('EE::Analytics::UsageTrends::Measurement') +Analytics::UsageTrends::Measurement.prepend_mod_with('Analytics::UsageTrends::Measurement') diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 1bbace791ed..5e5bc00458e 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -66,6 +66,12 @@ class ApplicationRecord < ActiveRecord::Base end end + def create_or_load_association(association_name) + association(association_name).create unless association(association_name).loaded? + rescue ActiveRecord::RecordNotUnique, PG::UniqueViolation + association(association_name).reader + end + def self.underscore Gitlab::SafeRequestStore.fetch("model:#{self}:underscore") { self.to_s.underscore } end @@ -80,4 +86,4 @@ class ApplicationRecord < ActiveRecord::Base end end -ApplicationRecord.prepend_if_ee('EE::ApplicationRecordHelpers') +ApplicationRecord.prepend_mod_with('ApplicationRecordHelpers') diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index f405f5ca5d3..65800e40d6c 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -13,6 +13,8 @@ class ApplicationSetting < ApplicationRecord KROKI_URL_ERROR_MESSAGE = 'Please check your Kroki URL setting in ' \ 'Admin Area > Settings > General > Kroki' + enum whats_new_variant: { all_tiers: 0, current_tier: 1, disabled: 2 }, _prefix: true + add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required } add_authentication_token_field :health_check_access_token add_authentication_token_field :static_objects_external_storage_auth_token @@ -132,6 +134,14 @@ class ApplicationSetting < ApplicationRecord presence: true, if: :akismet_enabled + validates :spam_check_api_key, + length: { maximum: 2000, message: _('is too long (maximum is %{count} characters)') }, + allow_blank: true + + validates :spam_check_api_key, + presence: true, + if: :spam_check_endpoint_enabled + validates :unique_ips_limit_per_user, numericality: { greater_than_or_equal_to: 1 }, presence: true, @@ -365,7 +375,7 @@ class ApplicationSetting < ApplicationRecord if: :external_authorization_service_enabled validates :spam_check_endpoint_url, - addressable_url: true, allow_blank: true + addressable_url: { schemes: %w(grpc) }, allow_blank: true validates :spam_check_endpoint_url, presence: true, @@ -434,6 +444,14 @@ class ApplicationSetting < ApplicationRecord presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :throttle_unauthenticated_packages_api_requests_per_period, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + + validates :throttle_unauthenticated_packages_api_period_in_seconds, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + validates :throttle_authenticated_api_requests_per_period, presence: true, numericality: { only_integer: true, greater_than: 0 } @@ -450,6 +468,14 @@ class ApplicationSetting < ApplicationRecord presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :throttle_authenticated_packages_api_requests_per_period, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + + validates :throttle_authenticated_packages_api_period_in_seconds, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + validates :throttle_protected_paths_requests_per_period, presence: true, numericality: { only_integer: true, greater_than: 0 } @@ -475,35 +501,43 @@ class ApplicationSetting < ApplicationRecord allow_nil: true, numericality: { only_integer: true, greater_than: 0 } + validates :whats_new_variant, + inclusion: { in: ApplicationSetting.whats_new_variants.keys } + + validates :floc_enabled, + inclusion: { in: [true, false], message: _('must be a boolean value') } + attr_encrypted :asset_proxy_secret_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, algorithm: 'aes-256-cbc', insecure_mode: true - private_class_method def self.encryption_options_base_truncated_aes_256_gcm + private_class_method def self.encryption_options_base_32_aes_256_gcm { mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: true } end - attr_encrypted :external_auth_client_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :external_auth_client_key_pass, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :lets_encrypt_private_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :eks_secret_access_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :akismet_api_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :elasticsearch_aws_secret_access_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :recaptcha_private_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :recaptcha_site_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :slack_app_secret, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :slack_app_verification_token, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :ci_jwt_signing_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :secret_detection_token_revocation_token, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :cloud_license_auth_token, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :external_pipeline_validation_service_token, encryption_options_base_truncated_aes_256_gcm + attr_encrypted :external_auth_client_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :external_auth_client_key_pass, encryption_options_base_32_aes_256_gcm + attr_encrypted :lets_encrypt_private_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :eks_secret_access_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :akismet_api_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :spam_check_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false) + attr_encrypted :elasticsearch_aws_secret_access_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :elasticsearch_password, encryption_options_base_32_aes_256_gcm.merge(encode: false) + attr_encrypted :recaptcha_private_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :recaptcha_site_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :slack_app_secret, encryption_options_base_32_aes_256_gcm + attr_encrypted :slack_app_verification_token, encryption_options_base_32_aes_256_gcm + attr_encrypted :ci_jwt_signing_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :secret_detection_token_revocation_token, encryption_options_base_32_aes_256_gcm + attr_encrypted :cloud_license_auth_token, encryption_options_base_32_aes_256_gcm + attr_encrypted :external_pipeline_validation_service_token, encryption_options_base_32_aes_256_gcm validates :disable_feed_token, inclusion: { in: [true, false], message: _('must be a boolean value') } @@ -634,4 +668,4 @@ class ApplicationSetting < ApplicationRecord end end -ApplicationSetting.prepend_if_ee('EE::ApplicationSetting') +ApplicationSetting.prepend_mod_with('ApplicationSetting') diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 66a8d1f8105..5ff1c653f9e 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -38,6 +38,7 @@ module ApplicationSettingImplementation admin_mode: false, after_sign_up_text: nil, akismet_enabled: false, + akismet_api_key: nil, allow_local_requests_from_system_hooks: true, allow_local_requests_from_web_hooks_and_services: false, asset_proxy_enabled: false, @@ -76,6 +77,7 @@ module ApplicationSettingImplementation external_pipeline_validation_service_token: nil, external_pipeline_validation_service_url: nil, first_day_of_week: 0, + floc_enabled: false, gitaly_timeout_default: 55, gitaly_timeout_fast: 10, gitaly_timeout_medium: 30, @@ -149,6 +151,7 @@ module ApplicationSettingImplementation sourcegraph_url: nil, spam_check_endpoint_enabled: false, spam_check_endpoint_url: nil, + spam_check_api_key: nil, terminal_max_session_time: 0, throttle_authenticated_api_enabled: false, throttle_authenticated_api_period_in_seconds: 3600, @@ -156,6 +159,9 @@ module ApplicationSettingImplementation throttle_authenticated_web_enabled: false, throttle_authenticated_web_period_in_seconds: 3600, throttle_authenticated_web_requests_per_period: 7200, + throttle_authenticated_packages_api_enabled: false, + throttle_authenticated_packages_api_period_in_seconds: 15, + throttle_authenticated_packages_api_requests_per_period: 1000, throttle_incident_management_notification_enabled: false, throttle_incident_management_notification_per_period: 3600, throttle_incident_management_notification_period_in_seconds: 3600, @@ -165,6 +171,9 @@ module ApplicationSettingImplementation throttle_unauthenticated_enabled: false, throttle_unauthenticated_period_in_seconds: 3600, throttle_unauthenticated_requests_per_period: 3600, + throttle_unauthenticated_packages_api_enabled: false, + throttle_unauthenticated_packages_api_period_in_seconds: 15, + throttle_unauthenticated_packages_api_requests_per_period: 800, time_tracking_limit_to_hours: false, two_factor_grace_period: 48, unique_ips_limit_enabled: false, @@ -181,7 +190,8 @@ module ApplicationSettingImplementation kroki_enabled: false, kroki_url: nil, kroki_formats: { blockdiag: false, bpmn: false, excalidraw: false }, - rate_limiting_response_text: nil + rate_limiting_response_text: nil, + whats_new_variant: 0 } end diff --git a/app/models/atlassian/identity.rb b/app/models/atlassian/identity.rb index 906f2be0fbf..02bbe007e1b 100644 --- a/app/models/atlassian/identity.rb +++ b/app/models/atlassian/identity.rb @@ -11,14 +11,14 @@ module Atlassian attr_encrypted :token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: false, encode_iv: false attr_encrypted :refresh_token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: false, encode_iv: false diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 32c9d44f836..aff7eef4622 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -92,4 +92,4 @@ class AuditEvent < ApplicationRecord end end -AuditEvent.prepend_if_ee('EE::AuditEvent') +AuditEvent.prepend_mod_with('AuditEvent') diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb index a851f22bfcd..a3801025cd7 100644 --- a/app/models/blob_viewer/dependency_manager.rb +++ b/app/models/blob_viewer/dependency_manager.rb @@ -33,7 +33,7 @@ module BlobViewer @json_data ||= begin prepare! Gitlab::Json.parse(blob.data) - rescue + rescue StandardError {} end end diff --git a/app/models/board.rb b/app/models/board.rb index b26a9461ffc..7938819b6e4 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -45,6 +45,12 @@ class Board < ApplicationRecord def to_type self.class.to_type end + + def disabled_for?(current_user) + namespace = group_board? ? resource_parent.root_ancestor : resource_parent.root_namespace + + namespace.issue_repositioning_disabled? || !Ability.allowed?(current_user, :create_non_backlog_issues, self) + end end -Board.prepend_if_ee('EE::Board') +Board.prepend_mod_with('Board') diff --git a/app/models/board_group_recent_visit.rb b/app/models/board_group_recent_visit.rb index 979f0e1ab92..dc273e256a8 100644 --- a/app/models/board_group_recent_visit.rb +++ b/app/models/board_group_recent_visit.rb @@ -2,27 +2,19 @@ # Tracks which boards in a specific group a user has visited class BoardGroupRecentVisit < ApplicationRecord + include BoardRecentVisit + belongs_to :user belongs_to :group belongs_to :board - validates :user, presence: true + validates :user, presence: true validates :group, presence: true validates :board, presence: true - scope :by_user_group, -> (user, group) { where(user: user, group: group) } - - def self.visited!(user, board) - visit = find_or_create_by(user: user, group: board.group, board: board) - visit.touch if visit.updated_at < Time.current - rescue ActiveRecord::RecordNotUnique - retry - end - - def self.latest(user, group, count: nil) - visits = by_user_group(user, group).order(updated_at: :desc) - visits = visits.preload(:board) if count && count > 1 + scope :by_user_parent, -> (user, group) { where(user: user, group: group) } - visits.first(count) + def self.board_parent_relation + :group end end diff --git a/app/models/board_project_recent_visit.rb b/app/models/board_project_recent_visit.rb index 509c8f97b83..723afd6feab 100644 --- a/app/models/board_project_recent_visit.rb +++ b/app/models/board_project_recent_visit.rb @@ -2,27 +2,19 @@ # Tracks which boards in a specific project a user has visited class BoardProjectRecentVisit < ApplicationRecord + include BoardRecentVisit + belongs_to :user belongs_to :project belongs_to :board - validates :user, presence: true + validates :user, presence: true validates :project, presence: true validates :board, presence: true - scope :by_user_project, -> (user, project) { where(user: user, project: project) } - - def self.visited!(user, board) - visit = find_or_create_by(user: user, project: board.project, board: board) - visit.touch if visit.updated_at < Time.current - rescue ActiveRecord::RecordNotUnique - retry - end - - def self.latest(user, project, count: nil) - visits = by_user_project(user, project).order(updated_at: :desc) - visits = visits.preload(:board) if count && count > 1 + scope :by_user_parent, -> (user, project) { where(user: user, project: project) } - visits.first(count) + def self.board_parent_relation + :project end end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index a8325e98095..1ee5c081840 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -106,6 +106,14 @@ class BroadcastMessage < ApplicationRecord return false if current_path.blank? && target_path.present? return true if current_path.blank? || target_path.blank? + # Ensure paths are consistent across callers. + # This fixes a mismatch between requests in the GUI and CLI + # + # This has to be reassigned due to frozen strings being provided. + unless current_path.start_with?("/") + current_path = "/#{current_path}" + end + escaped = Regexp.escape(target_path).gsub('\\*', '.*') regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE @@ -119,4 +127,4 @@ class BroadcastMessage < ApplicationRecord end end -BroadcastMessage.prepend_if_ee('EE::BroadcastMessage') +BroadcastMessage.prepend_mod_with('BroadcastMessage') diff --git a/app/models/bulk_imports/configuration.rb b/app/models/bulk_imports/configuration.rb index 4c6f745c268..6d9f598583e 100644 --- a/app/models/bulk_imports/configuration.rb +++ b/app/models/bulk_imports/configuration.rb @@ -12,11 +12,11 @@ class BulkImports::Configuration < ApplicationRecord allow_nil: true attr_encrypted :url, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, mode: :per_attribute_iv, algorithm: 'aes-256-gcm' attr_encrypted :access_token, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, mode: :per_attribute_iv, algorithm: 'aes-256-gcm' end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 04af1145769..bb543b39a79 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -68,6 +68,10 @@ class BulkImports::Entity < ApplicationRecord end end + def encoded_source_full_path + ERB::Util.url_encode(source_full_path) + end + private def validate_parent_is_a_group diff --git a/app/models/bulk_imports/export.rb b/app/models/bulk_imports/export.rb new file mode 100644 index 00000000000..59ca4dbfec6 --- /dev/null +++ b/app/models/bulk_imports/export.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module BulkImports + class Export < ApplicationRecord + include Gitlab::Utils::StrongMemoize + + self.table_name = 'bulk_import_exports' + + belongs_to :project, optional: true + belongs_to :group, optional: true + + has_one :upload, class_name: 'BulkImports::ExportUpload' + + validates :project, presence: true, unless: :group + validates :group, presence: true, unless: :project + validates :relation, :status, presence: true + + validate :portable_relation? + + state_machine :status, initial: :started do + state :started, value: 0 + state :finished, value: 1 + state :failed, value: -1 + + event :start do + transition any => :started + end + + event :finish do + transition started: :finished + transition failed: :failed + end + + event :fail_op do + transition any => :failed + end + end + + def portable_relation? + return unless portable + + errors.add(:relation, 'Unsupported portable relation') unless config.portable_relations.include?(relation) + end + + def portable + strong_memoize(:portable) do + project || group + end + end + + def relation_definition + config.portable_tree[:include].find { |include| include[relation.to_sym] } + end + + def config + strong_memoize(:config) do + FileTransfer.config_for(portable) + end + end + end +end diff --git a/app/models/bulk_imports/export_upload.rb b/app/models/bulk_imports/export_upload.rb new file mode 100644 index 00000000000..a9cba5119af --- /dev/null +++ b/app/models/bulk_imports/export_upload.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module BulkImports + class ExportUpload < ApplicationRecord + include WithUploads + include ObjectStorage::BackgroundMove + + self.table_name = 'bulk_import_export_uploads' + + belongs_to :export, class_name: 'BulkImports::Export' + + mount_uploader :export_file, ExportUploader + + def retrieve_upload(_identifier, paths) + Upload.find_by(model: self, path: paths) + end + end +end diff --git a/app/models/bulk_imports/file_transfer.rb b/app/models/bulk_imports/file_transfer.rb new file mode 100644 index 00000000000..5be954b98da --- /dev/null +++ b/app/models/bulk_imports/file_transfer.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module BulkImports + module FileTransfer + extend self + + UnsupportedObjectType = Class.new(StandardError) + + def config_for(portable) + case portable + when ::Project + FileTransfer::ProjectConfig.new(portable) + when ::Group + FileTransfer::GroupConfig.new(portable) + else + raise(UnsupportedObjectType, "Unsupported object type: #{portable.class}") + end + end + end +end diff --git a/app/models/bulk_imports/file_transfer/base_config.rb b/app/models/bulk_imports/file_transfer/base_config.rb new file mode 100644 index 00000000000..bb04e84ad72 --- /dev/null +++ b/app/models/bulk_imports/file_transfer/base_config.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module BulkImports + module FileTransfer + class BaseConfig + include Gitlab::Utils::StrongMemoize + + def initialize(portable) + @portable = portable + end + + def portable_tree + attributes_finder.find_root(portable_class_sym) + end + + def export_path + strong_memoize(:export_path) do + relative_path = File.join(base_export_path, SecureRandom.hex) + + ::Gitlab::ImportExport.export_path(relative_path: relative_path) + end + end + + def portable_relations + import_export_config.dig(:tree, portable_class_sym).keys.map(&:to_s) + end + + private + + attr_reader :portable + + def attributes_finder + strong_memoize(:attributes_finder) do + ::Gitlab::ImportExport::AttributesFinder.new(config: import_export_config) + end + end + + def import_export_config + ::Gitlab::ImportExport::Config.new(config: import_export_yaml).to_h + end + + def portable_class + @portable_class ||= portable.class + end + + def portable_class_sym + @portable_class_sym ||= portable_class.to_s.demodulize.underscore.to_sym + end + + def import_export_yaml + raise NotImplementedError + end + + def base_export_path + raise NotImplementedError + end + end + end +end diff --git a/app/models/bulk_imports/file_transfer/group_config.rb b/app/models/bulk_imports/file_transfer/group_config.rb new file mode 100644 index 00000000000..1f845b387b8 --- /dev/null +++ b/app/models/bulk_imports/file_transfer/group_config.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module BulkImports + module FileTransfer + class GroupConfig < BaseConfig + def base_export_path + portable.full_path + end + + def import_export_yaml + ::Gitlab::ImportExport.group_config_file + end + end + end +end diff --git a/app/models/bulk_imports/file_transfer/project_config.rb b/app/models/bulk_imports/file_transfer/project_config.rb new file mode 100644 index 00000000000..e42b5bfce3d --- /dev/null +++ b/app/models/bulk_imports/file_transfer/project_config.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module BulkImports + module FileTransfer + class ProjectConfig < BaseConfig + def base_export_path + portable.disk_path + end + + def import_export_yaml + ::Gitlab::ImportExport.config_file + end + end + end +end diff --git a/app/models/bulk_imports/stage.rb b/app/models/bulk_imports/stage.rb deleted file mode 100644 index 050c2c76ce8..00000000000 --- a/app/models/bulk_imports/stage.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -module BulkImports - class Stage - include Singleton - - CONFIG = { - group: { - pipeline: BulkImports::Groups::Pipelines::GroupPipeline, - stage: 0 - }, - subgroups: { - pipeline: BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, - stage: 1 - }, - members: { - pipeline: BulkImports::Groups::Pipelines::MembersPipeline, - stage: 1 - }, - labels: { - pipeline: BulkImports::Groups::Pipelines::LabelsPipeline, - stage: 1 - }, - milestones: { - pipeline: BulkImports::Groups::Pipelines::MilestonesPipeline, - stage: 1 - }, - badges: { - pipeline: BulkImports::Groups::Pipelines::BadgesPipeline, - stage: 1 - }, - finisher: { - pipeline: BulkImports::Groups::Pipelines::EntityFinisher, - stage: 2 - } - }.freeze - - def self.pipelines - instance.pipelines - end - - def self.pipeline_exists?(name) - pipelines.any? do |(_, pipeline)| - pipeline.to_s == name.to_s - end - end - - def pipelines - @pipelines ||= config - .values - .sort_by { |entry| entry[:stage] } - .map do |entry| - [entry[:stage], entry[:pipeline]] - end - end - - private - - def config - @config ||= CONFIG - end - end -end - -::BulkImports::Stage.prepend_if_ee('::EE::BulkImports::Stage') diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb index 282ba9e19ac..1b108d5c042 100644 --- a/app/models/bulk_imports/tracker.rb +++ b/app/models/bulk_imports/tracker.rb @@ -35,7 +35,7 @@ class BulkImports::Tracker < ApplicationRecord def pipeline_class unless BulkImports::Stage.pipeline_exists?(pipeline_name) - raise NameError.new("'#{pipeline_name}' is not a valid BulkImport Pipeline") + raise NameError, "'#{pipeline_name}' is not a valid BulkImport Pipeline" end pipeline_name.constantize diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb index 0041595baba..ff3f2663b73 100644 --- a/app/models/chat_name.rb +++ b/app/models/chat_name.rb @@ -3,11 +3,11 @@ class ChatName < ApplicationRecord LAST_USED_AT_INTERVAL = 1.hour - belongs_to :service + belongs_to :integration, foreign_key: :service_id belongs_to :user validates :user, presence: true - validates :service, presence: true + validates :integration, presence: true validates :team_id, presence: true validates :chat_id, presence: true diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index ca400cebe4e..352229c64da 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -163,6 +163,9 @@ module Ci def expanded_environment_name end + def instantized_environment + end + def execute_hooks raise NotImplementedError end @@ -248,4 +251,4 @@ module Ci end end -::Ci::Bridge.prepend_if_ee('::EE::Ci::Bridge') +::Ci::Bridge.prepend_mod_with('Ci::Bridge') diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 3d8e9f4c126..46fc87a6ea8 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -62,6 +62,9 @@ module Ci delegate :gitlab_deploy_token, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true + ignore_columns :id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' + ignore_columns :stage_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' + ## # Since Gitlab 11.5, deployments records started being created right after # `ci_builds` creation. We can look up a relevant `environment` through @@ -85,6 +88,16 @@ module Ci end end + # Initializing an object instead of fetching `persisted_environment` for avoiding unnecessary queries. + # We're planning to introduce a direct relationship between build and environment + # in https://gitlab.com/gitlab-org/gitlab/-/issues/326445 to let us to preload + # in batch. + def instantized_environment + return unless has_environment? + + ::Environment.new(project: self.project, name: self.expanded_environment_name) + end + serialize :options # rubocop:disable Cop/ActiveRecordSerialize serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiveRecordSerialize @@ -330,7 +343,7 @@ module Ci begin build.deployment.drop! - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, build_id: build.id) end @@ -1047,7 +1060,7 @@ module Ci end def build_data - @build_data ||= Gitlab::DataBuilder::Build.build(self) + strong_memoize(:build_data) { Gitlab::DataBuilder::Build.build(self) } end def successful_deployment_status @@ -1141,4 +1154,4 @@ module Ci end end -Ci::Build.prepend_if_ee('EE::Ci::Build') +Ci::Build.prepend_mod_with('Ci::Build') diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb index 8ae921f1416..716d919487d 100644 --- a/app/models/ci/build_dependencies.rb +++ b/app/models/ci/build_dependencies.rb @@ -14,14 +14,33 @@ module Ci (local + cross_pipeline + cross_project).uniq end + def invalid_local + local.reject(&:valid_dependency?) + end + + def valid? + valid_local? && valid_cross_pipeline? && valid_cross_project? + end + + private + + # Dependencies can only be of Ci::Build type because only builds + # can create artifacts + def model_class + ::Ci::Build + end + # Dependencies local to the given pipeline def local - return [] if no_local_dependencies_specified? - - deps = model_class.where(pipeline_id: processable.pipeline_id).latest - deps = from_previous_stages(deps) - deps = from_needs(deps) - from_dependencies(deps) + strong_memoize(:local) do + next [] if no_local_dependencies_specified? + next [] unless processable.pipeline_id # we don't have any dependency when creating the pipeline + + deps = model_class.where(pipeline_id: processable.pipeline_id).latest + deps = from_previous_stages(deps) + deps = from_needs(deps) + from_dependencies(deps).to_a + end end # Dependencies from the same parent-pipeline hierarchy excluding @@ -37,22 +56,6 @@ module Ci [] end - def invalid_local - local.reject(&:valid_dependency?) - end - - def valid? - valid_local? && valid_cross_pipeline? && valid_cross_project? - end - - private - - # Dependencies can only be of Ci::Build type because only builds - # can create artifacts - def model_class - ::Ci::Build - end - def fetch_dependencies_in_hierarchy deps_specifications = specified_cross_pipeline_dependencies return [] if deps_specifications.empty? @@ -102,8 +105,6 @@ module Ci end def valid_local? - return true unless Gitlab::Ci::Features.validate_build_dependencies?(project) - local.all?(&:valid_dependency?) end @@ -154,4 +155,4 @@ module Ci end end -Ci::BuildDependencies.prepend_if_ee('EE::Ci::BuildDependencies') +Ci::BuildDependencies.prepend_mod_with('Ci::BuildDependencies') diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index 7bc70f9f1e1..4a59c25cbb0 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -5,6 +5,9 @@ module Ci extend Gitlab::Ci::Model include BulkInsertSafe + include IgnorableColumns + + ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs @@ -14,5 +17,12 @@ module Ci scope :scoped_build, -> { where('ci_builds.id=ci_build_needs.build_id') } scope :artifacts, -> { where(artifacts: true) } + + # TODO: Remove once build_id_convert_to_bigint is not an "ignored" column anymore (see .ignore_columns above) + # There is a database-side trigger to populate this column. This is unexpected in the context + # of cloning an instance, e.g. when retrying the job. Hence we exclude the ignored column explicitly here. + def attributes + super.except('build_id_convert_to_bigint') + end end end diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index b6196048ca1..2aa856dbc64 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -5,6 +5,9 @@ module Ci # Data will be removed after transitioning from running to any state. class BuildRunnerSession < ApplicationRecord extend Gitlab::Ci::Model + include IgnorableColumns + + ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com' DEFAULT_SERVICE_NAME = 'build' diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 7e03d709f24..719511bbb8a 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -8,6 +8,9 @@ module Ci include ::Checksummable include ::Gitlab::ExclusiveLeaseHelpers include ::Gitlab::OptimisticLocking + include IgnorableColumns + + ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id diff --git a/app/models/ci/commit_with_pipeline.rb b/app/models/ci/commit_with_pipeline.rb index 7f952fb77a0..dde4b534aaa 100644 --- a/app/models/ci/commit_with_pipeline.rb +++ b/app/models/ci/commit_with_pipeline.rb @@ -18,9 +18,25 @@ class Ci::CommitWithPipeline < SimpleDelegator end end + def lazy_latest_pipeline + BatchLoader.for(sha).batch do |shas, loader| + preload_pipelines = project.ci_pipelines.latest_pipeline_per_commit(shas.compact) + + shas.each do |sha| + pipeline = preload_pipelines[sha] + + loader.call(sha, pipeline) + end + end + end + def latest_pipeline(ref = nil) @latest_pipelines.fetch(ref) do |ref| - @latest_pipelines[ref] = latest_pipeline_for_project(ref, project) + @latest_pipelines[ref] = if ref + latest_pipeline_for_project(ref, project) + else + lazy_latest_pipeline&.itself + end end end diff --git a/app/models/ci/daily_build_group_report_result.rb b/app/models/ci/daily_build_group_report_result.rb index 5dcf575abd7..b46d32474c6 100644 --- a/app/models/ci/daily_build_group_report_result.rb +++ b/app/models/ci/daily_build_group_report_result.rb @@ -30,4 +30,4 @@ module Ci end end -Ci::DailyBuildGroupReportResult.prepend_if_ee('EE::Ci::DailyBuildGroupReportResult') +Ci::DailyBuildGroupReportResult.prepend_mod_with('Ci::DailyBuildGroupReportResult') diff --git a/app/models/ci/deleted_object.rb b/app/models/ci/deleted_object.rb index 2942a153e05..b2a949c9bb5 100644 --- a/app/models/ci/deleted_object.rb +++ b/app/models/ci/deleted_object.rb @@ -29,7 +29,7 @@ module Ci def delete_file_from_storage file.remove! true - rescue => exception + rescue StandardError => exception Gitlab::ErrorTracking.track_exception(exception) false end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 50e21a1c323..5248a80f710 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -261,6 +261,22 @@ module Ci self.where(project: project).sum(:size) end + ## + # FastDestroyAll concerns + # rubocop: disable CodeReuse/ServiceClass + def self.begin_fast_destroy + service = ::Ci::JobArtifacts::DestroyAssociationsService.new(self) + service.destroy_records + service + end + # rubocop: enable CodeReuse/ServiceClass + + ## + # FastDestroyAll concerns + def self.finalize_fast_destroy(service) + service.update_statistics + end + def local_store? [nil, ::JobArtifactUploader::Store::LOCAL].include?(self.file_store) end @@ -331,4 +347,4 @@ module Ci end end -Ci::JobArtifact.prepend_if_ee('EE::Ci::JobArtifact') +Ci::JobArtifact.prepend_mod_with('Ci::JobArtifact') diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb index 91163c85a9e..57aa1962bd2 100644 --- a/app/models/ci/persistent_ref.rb +++ b/app/models/ci/persistent_ref.rb @@ -15,13 +15,13 @@ module Ci def exist? ref_exists?(path) - rescue + rescue StandardError false end def create create_ref(sha, path) - rescue => e + rescue StandardError => e Gitlab::ErrorTracking .track_exception(e, pipeline_id: pipeline.id) end @@ -30,7 +30,7 @@ module Ci delete_refs(path) rescue Gitlab::Git::Repository::NoRepository # no-op - rescue => e + rescue StandardError => e Gitlab::ErrorTracking .track_exception(e, pipeline_id: pipeline.id) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index c9ab69317e1..f0a2c074584 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -17,6 +17,7 @@ module Ci include FromUnion include UpdatedAtFilterable include EachBatch + include FastDestroyAll::Helpers MAX_OPEN_MERGE_REQUESTS_REFS = 4 @@ -70,7 +71,9 @@ module Ci has_many :deployments, through: :builds has_many :environments, -> { distinct }, through: :deployments has_many :latest_builds, -> { latest.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build' - has_many :downloadable_artifacts, -> { not_expired.downloadable.with_job }, through: :latest_builds, source: :job_artifacts + has_many :downloadable_artifacts, -> do + not_expired.or(where_exists(::Ci::Pipeline.artifacts_locked.where('ci_pipelines.id = ci_builds.commit_id'))).downloadable.with_job + end, through: :latest_builds, source: :job_artifacts has_many :messages, class_name: 'Ci::PipelineMessage', inverse_of: :pipeline @@ -124,6 +127,8 @@ module Ci after_create :keep_around_commits, unless: :importing? + use_fast_destroy :job_artifacts + # We use `Enums::Ci::Pipeline.sources` here so that EE can more easily extend # this `Hash` with new values. enum_with_nil source: Enums::Ci::Pipeline.sources @@ -908,7 +913,7 @@ module Ci def same_family_pipeline_ids ::Gitlab::Ci::PipelineObjectHierarchy.new( - self.class.where(id: root_ancestor), options: { same_project: true } + self.class.default_scoped.where(id: root_ancestor), options: { same_project: true } ).base_and_descendants.select(:id) end @@ -1093,6 +1098,8 @@ module Ci merge_request.modified_paths elsif branch_updated? push_details.modified_paths + elsif external_pull_request? && ::Feature.enabled?(:ci_modified_paths_of_external_prs, project, default_enabled: :yaml) + external_pull_request.modified_paths end end end @@ -1117,6 +1124,10 @@ module Ci merge_request_id.present? end + def external_pull_request? + external_pull_request_id.present? + end + def detached_merge_request_pipeline? merge_request? && target_sha.nil? end @@ -1210,11 +1221,18 @@ module Ci # We need `base_and_ancestors` in a specific order to "break" when needed. # If we use `find_each`, then the order is broken. # rubocop:disable Rails/FindEach - def reset_ancestor_bridges! - base_and_ancestors.includes(:source_bridge).each do |pipeline| - break unless pipeline.bridge_waiting? + def reset_source_bridge!(current_user) + if ::Feature.enabled?(:ci_reset_bridge_with_subsequent_jobs, project, default_enabled: :yaml) + return unless bridge_waiting? - pipeline.source_bridge.pending! + source_bridge.pending! + Ci::AfterRequeueJobService.new(project, current_user).execute(source_bridge) # rubocop:disable CodeReuse/ServiceClass + else + base_and_ancestors.includes(:source_bridge).each do |pipeline| + break unless pipeline.bridge_waiting? + + pipeline.source_bridge.pending! + end end end # rubocop:enable Rails/FindEach @@ -1237,8 +1255,6 @@ module Ci private def add_message(severity, content) - return unless Gitlab::Ci::Features.store_pipeline_messages?(project) - messages.build(severity: severity, content: content) end @@ -1294,4 +1310,4 @@ module Ci end end -Ci::Pipeline.prepend_if_ee('EE::Ci::Pipeline') +Ci::Pipeline.prepend_mod_with('Ci::Pipeline') diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb index 9dfe4252e95..889c5d094a7 100644 --- a/app/models/ci/pipeline_artifact.rb +++ b/app/models/ci/pipeline_artifact.rb @@ -40,6 +40,8 @@ module Ci code_quality_mr_diff: 2 } + scope :unlocked, -> { joins(:pipeline).merge(::Ci::Pipeline.unlocked) } + class << self def report_exists?(file_type) return false unless REPORT_TYPES.key?(file_type) @@ -58,4 +60,4 @@ module Ci end end -Ci::PipelineArtifact.prepend_ee_mod +Ci::PipelineArtifact.prepend_mod diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 3c17246bc34..9e5d517c1fe 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -5,7 +5,7 @@ module Ci extend Gitlab::Ci::Model include Importable include StripAttribute - include Schedulable + include CronSchedulable include Limitable include EachBatch @@ -51,38 +51,16 @@ module Ci update_attribute(:active, false) end - ## - # The `next_run_at` column is set to the actual execution date of `PipelineScheduleWorker`. - # This way, a schedule like `*/1 * * * *` won't be triggered in a short interval - # when PipelineScheduleWorker runs irregularly by Sidekiq Memory Killer. - def set_next_run_at - now = Time.zone.now - ideal_next_run = ideal_next_run_from(now) - - self.next_run_at = if ideal_next_run == cron_worker_next_run_from(now) - ideal_next_run - else - cron_worker_next_run_from(ideal_next_run) - end - end - def job_variables variables&.map(&:to_runner_variable) || [] end private - def ideal_next_run_from(start_time) - Gitlab::Ci::CronParser.new(cron, cron_timezone) - .next_time_from(start_time) - end - - def cron_worker_next_run_from(start_time) - Gitlab::Ci::CronParser.new(Settings.cron_jobs['pipeline_schedule_worker']['cron'], - Time.zone.name) - .next_time_from(start_time) + def worker_cron_expression + Settings.cron_jobs['pipeline_schedule_worker']['cron'] end end end -Ci::PipelineSchedule.prepend_if_ee('EE::Ci::PipelineSchedule') +Ci::PipelineSchedule.prepend_mod_with('Ci::PipelineSchedule') diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 3b61840805a..15c57550159 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -120,6 +120,10 @@ module Ci raise NotImplementedError end + def instantized_environment + raise NotImplementedError + end + override :all_met_to_become_pending? def all_met_to_become_pending? super && !with_resource_group? diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 05126853e0f..8c877c2b818 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -39,16 +39,16 @@ module Ci AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze AVAILABLE_TYPES = runner_types.keys.freeze - AVAILABLE_STATUSES = %w[active paused online offline].freeze + AVAILABLE_STATUSES = %w[active paused online offline not_connected].freeze AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze MINUTES_COST_FACTOR_FIELDS = %i[public_projects_minutes_cost_factor private_projects_minutes_cost_factor].freeze has_many :builds - has_many :runner_projects, inverse_of: :runner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :runner_projects, inverse_of: :runner, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :runner_projects - has_many :runner_namespaces, inverse_of: :runner + has_many :runner_namespaces, inverse_of: :runner, autosave: true has_many :groups, through: :runner_namespaces has_one :last_build, -> { order('id DESC') }, class_name: 'Ci::Build' @@ -65,6 +65,7 @@ module Ci # did `contacted_at <= ?` the query would effectively have to do a seq # scan. scope :offline, -> { where.not(id: online) } + scope :not_connected, -> { where(contacted_at: nil) } scope :ordered, -> { order(id: :desc) } scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) } @@ -405,4 +406,4 @@ module Ci end end -Ci::Runner.prepend_if_ee('EE::Ci::Runner') +Ci::Runner.prepend_mod_with('Ci::Runner') diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb index e6c1899c89d..f819dda207d 100644 --- a/app/models/ci/runner_namespace.rb +++ b/app/models/ci/runner_namespace.rb @@ -3,6 +3,11 @@ module Ci class RunnerNamespace < ApplicationRecord extend Gitlab::Ci::Model + include Limitable + + self.limit_name = 'ci_registered_group_runners' + self.limit_scope = :group + self.limit_feature_flag = :ci_runner_limits belongs_to :runner, inverse_of: :runner_namespaces belongs_to :namespace, inverse_of: :runner_namespaces, class_name: '::Namespace' diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb index f5bd50dc5a3..c26b8183b52 100644 --- a/app/models/ci/runner_project.rb +++ b/app/models/ci/runner_project.rb @@ -3,6 +3,11 @@ module Ci class RunnerProject < ApplicationRecord extend Gitlab::Ci::Model + include Limitable + + self.limit_name = 'ci_registered_project_runners' + self.limit_scope = :project + self.limit_feature_flag = :ci_runner_limits belongs_to :runner, inverse_of: :runner_projects belongs_to :project, inverse_of: :runner_projects diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 5ae97dcd495..ef920b2d589 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -41,7 +41,7 @@ module Ci self.position = statuses.select(:stage_idx) .where.not(stage_idx: nil) .group(:stage_idx) - .order('COUNT(*) DESC') + .order('COUNT(id) DESC') .first&.stage_idx.to_i end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 85cb3f5b46a..6e27abb9f5b 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -37,4 +37,4 @@ module Ci end end -Ci::Trigger.prepend_if_ee('EE::Ci::Trigger') +Ci::Trigger.prepend_mod_with('Ci::Trigger') diff --git a/app/models/ci/unit_test.rb b/app/models/ci/unit_test.rb index 81623b4f6ad..9fddd9c6002 100644 --- a/app/models/ci/unit_test.rb +++ b/app/models/ci/unit_test.rb @@ -14,6 +14,7 @@ module Ci belongs_to :project scope :by_project_and_keys, -> (project, keys) { where(project_id: project.id, key_hash: keys) } + scope :deletable, -> { where('NOT EXISTS (?)', Ci::UnitTestFailure.select(1).where("#{Ci::UnitTestFailure.table_name}.unit_test_id = #{table_name}.id")) } class << self def find_or_create_by_batch(project, unit_test_attrs) diff --git a/app/models/ci/unit_test_failure.rb b/app/models/ci/unit_test_failure.rb index 653a56bd2b3..480f9cefb8e 100644 --- a/app/models/ci/unit_test_failure.rb +++ b/app/models/ci/unit_test_failure.rb @@ -11,6 +11,8 @@ module Ci belongs_to :unit_test, class_name: "Ci::UnitTest", foreign_key: :unit_test_id belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id + scope :deletable, -> { where('failed_at < ?', REPORT_WINDOW.ago) } + def self.recent_failures_count(project:, unit_test_keys:, date_range: REPORT_WINDOW.ago..Time.current) joins(:unit_test) .where( diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index c5b9dddb1da..9fb8cd024c5 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -8,6 +8,7 @@ module Clusters belongs_to :project, class_name: '::Project' # Otherwise, it will load ::Clusters::Project has_many :agent_tokens, class_name: 'Clusters::AgentToken' + has_many :last_used_agent_tokens, -> { order_last_used_at_desc }, class_name: 'Clusters::AgentToken', inverse_of: :agent scope :ordered_by_name, -> { order(:name) } scope :with_name, -> (name) { where(name: name) } diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb index d42279502c5..27a3cd8d13d 100644 --- a/app/models/clusters/agent_token.rb +++ b/app/models/clusters/agent_token.rb @@ -6,7 +6,7 @@ module Clusters include TokenAuthenticatable add_authentication_token_field :token, encrypted: :required, token_generator: -> { Devise.friendly_token(50) } - cached_attr_reader :last_contacted_at + cached_attr_reader :last_used_at self.table_name = 'cluster_agent_tokens' @@ -21,6 +21,8 @@ module Clusters validates :description, length: { maximum: 1024 } validates :name, presence: true, length: { maximum: 255 } + scope :order_last_used_at_desc, -> { order(::Gitlab::Database.nulls_last_order('last_used_at', 'DESC')) } + def track_usage track_values = { last_used_at: Time.current.utc } diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb index db18a29ec84..73c731aab1a 100644 --- a/app/models/clusters/applications/elastic_stack.rb +++ b/app/models/clusters/applications/elastic_stack.rb @@ -3,9 +3,9 @@ module Clusters module Applications class ElasticStack < ApplicationRecord - VERSION = '3.0.0' + include ::Clusters::Concerns::ElasticsearchClient - ELASTICSEARCH_PORT = 9200 + VERSION = '3.0.0' self.table_name = 'clusters_applications_elastic_stacks' @@ -13,10 +13,23 @@ module Clusters include ::Clusters::Concerns::ApplicationStatus include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData - include ::Gitlab::Utils::StrongMemoize default_value_for :version, VERSION + after_destroy do + cluster&.find_or_build_integration_elastic_stack&.update(enabled: false, chart_version: nil) + end + + state_machine :status do + after_transition any => [:installed] do |application| + application.cluster&.find_or_build_integration_elastic_stack&.update(enabled: true, chart_version: application.version) + end + + after_transition any => [:uninstalled] do |application| + application.cluster&.find_or_build_integration_elastic_stack&.update(enabled: false, chart_version: nil) + end + end + def chart 'elastic-stack/elastic-stack' end @@ -51,31 +64,6 @@ module Clusters super.merge('wait-for-elasticsearch.sh': File.read("#{Rails.root}/vendor/elastic_stack/wait-for-elasticsearch.sh")) end - def elasticsearch_client(timeout: nil) - strong_memoize(:elasticsearch_client) do - next unless kube_client - - proxy_url = kube_client.proxy_url('service', service_name, ::Clusters::Applications::ElasticStack::ELASTICSEARCH_PORT, Gitlab::Kubernetes::Helm::NAMESPACE) - - Elasticsearch::Client.new(url: proxy_url) do |faraday| - # ensures headers containing auth data are appended to original client options - faraday.headers.merge!(kube_client.headers) - # ensure TLS certs are properly verified - faraday.ssl[:verify] = kube_client.ssl_options[:verify_ssl] - faraday.ssl[:cert_store] = kube_client.ssl_options[:cert_store] - faraday.options.timeout = timeout unless timeout.nil? - end - - rescue Kubeclient::HttpError => error - # If users have mistakenly set parameters or removed the depended clusters, - # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. - # We check for a nil client in downstream use and behaviour is equivalent to an empty state - log_exception(error, :failed_to_create_elasticsearch_client) - - nil - end - end - def chart_above_v2? Gem::Version.new(version) >= Gem::Version.new('2.0.0') end @@ -106,10 +94,6 @@ module Clusters ] end - def kube_client - cluster&.kubeclient&.core_client - end - def migrate_to_3_script return [] if !updating? || chart_above_v3? diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index b9c136abab4..21f7e410843 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -22,21 +22,18 @@ module Clusters attr_encrypted :alert_manager_token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' + default_value_for(:alert_manager_token) { SecureRandom.hex } + after_destroy do - run_after_commit do - disable_prometheus_integration - end + cluster.find_or_build_integration_prometheus.destroy end state_machine :status do after_transition any => [:installed, :externally_installed] do |application| - application.run_after_commit do - Clusters::Applications::ActivateServiceWorker - .perform_async(application.cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass - end + application.cluster.find_or_build_integration_prometheus.update(enabled: true, alert_manager_token: application.alert_manager_token) end after_transition any => :updating do |application| @@ -44,6 +41,10 @@ module Clusters end end + def managed_prometheus? + !externally_installed? && !uninstalled? + end + def updated_since?(timestamp) last_update_started_at && last_update_started_at > timestamp && @@ -70,6 +71,7 @@ module Clusters ) end + # Deprecated, to be removed in %14.0 as part of https://gitlab.com/groups/gitlab-org/-/epics/4280 def patch_command(values) helm_command_module::PatchCommand.new( name: name, @@ -98,23 +100,8 @@ module Clusters files.merge('values.yaml': replaced_values) end - def generate_alert_manager_token! - unless alert_manager_token.present? - update!(alert_manager_token: generate_token) - end - end - private - def generate_token - SecureRandom.hex - end - - def disable_prometheus_integration - ::Clusters::Applications::DeactivateServiceWorker - .perform_async(cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass - end - def install_knative_metrics return [] unless cluster.application_knative_available? diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index bc80bcd0b06..e8d56072b89 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.27.0' + VERSION = '0.28.0' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index a1e2aa194a0..4877ced795c 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -52,6 +52,7 @@ module Clusters has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', inverse_of: :cluster, autosave: true has_one :integration_prometheus, class_name: 'Clusters::Integrations::Prometheus', inverse_of: :cluster + has_one :integration_elastic_stack, class_name: 'Clusters::Integrations::ElasticStack', inverse_of: :cluster def self.has_one_cluster_application(name) # rubocop:disable Naming/PredicateName application = APPLICATIONS[name.to_s] @@ -104,6 +105,7 @@ module Clusters delegate :available?, to: :application_ingress, prefix: true, allow_nil: true delegate :available?, to: :application_knative, prefix: true, allow_nil: true delegate :available?, to: :application_elastic_stack, prefix: true, allow_nil: true + delegate :available?, to: :integration_elastic_stack, prefix: true, allow_nil: true delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true delegate :external_hostname, to: :application_ingress, prefix: true, allow_nil: true @@ -284,6 +286,10 @@ module Clusters integration_prometheus || build_integration_prometheus end + def find_or_build_integration_elastic_stack + integration_elastic_stack || build_integration_elastic_stack + end + def provider if gcp? provider_gcp @@ -318,6 +324,22 @@ module Clusters platform_kubernetes.kubeclient if kubernetes? end + def elastic_stack_adapter + application_elastic_stack || integration_elastic_stack + end + + def elasticsearch_client + elastic_stack_adapter&.elasticsearch_client + end + + def elastic_stack_available? + if application_elastic_stack_available? || integration_elastic_stack_available? + true + else + false + end + end + def kubernetes_namespace_for(environment, deployable: environment.last_deployable) if deployable && environment.project_id != deployable.project_id raise ArgumentError, 'environment.project_id must match deployable.project_id' @@ -470,4 +492,4 @@ module Clusters end end -Clusters::Cluster.prepend_if_ee('EE::Clusters::Cluster') +Clusters::Cluster.prepend_mod_with('Clusters::Cluster') diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index ad6699daa78..2e40689a650 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -6,6 +6,8 @@ module Clusters extend ActiveSupport::Concern included do + include ::Clusters::Concerns::KubernetesLogger + belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id validates :cluster, presence: true @@ -79,27 +81,9 @@ module Clusters # Override if your application needs any action after # being uninstalled by Helm end - - def logger - @logger ||= Gitlab::Kubernetes::Logger.build - end - - def log_exception(error, event) - logger.error({ - exception: error.class.name, - status_code: error.error_code, - cluster_id: cluster&.id, - application_id: id, - class_name: self.class.name, - event: event, - message: error.message - }) - - Gitlab::ErrorTracking.track_exception(error, cluster_id: cluster&.id, application_id: id) - end end end end end -Clusters::Concerns::ApplicationCore.prepend_if_ee('EE::Clusters::Concerns::ApplicationCore') +Clusters::Concerns::ApplicationCore.prepend_mod_with('Clusters::Concerns::ApplicationCore') diff --git a/app/models/clusters/concerns/elasticsearch_client.rb b/app/models/clusters/concerns/elasticsearch_client.rb new file mode 100644 index 00000000000..7b0b6bdae02 --- /dev/null +++ b/app/models/clusters/concerns/elasticsearch_client.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Clusters + module Concerns + module ElasticsearchClient + include ::Gitlab::Utils::StrongMemoize + + ELASTICSEARCH_PORT = 9200 + ELASTICSEARCH_NAMESPACE = 'gitlab-managed-apps' + + def elasticsearch_client(timeout: nil) + strong_memoize(:elasticsearch_client) do + kube_client = cluster&.kubeclient&.core_client + next unless kube_client + + proxy_url = kube_client.proxy_url('service', service_name, ELASTICSEARCH_PORT, ELASTICSEARCH_NAMESPACE) + + Elasticsearch::Client.new(url: proxy_url) do |faraday| + # ensures headers containing auth data are appended to original client options + faraday.headers.merge!(kube_client.headers) + # ensure TLS certs are properly verified + faraday.ssl[:verify] = kube_client.ssl_options[:verify_ssl] + faraday.ssl[:cert_store] = kube_client.ssl_options[:cert_store] + faraday.options.timeout = timeout unless timeout.nil? + end + + rescue Kubeclient::HttpError => error + # If users have mistakenly set parameters or removed the depended clusters, + # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. + # We check for a nil client in downstream use and behaviour is equivalent to an empty state + log_exception(error, :failed_to_create_elasticsearch_client) + + nil + end + end + end + end +end diff --git a/app/models/clusters/concerns/kubernetes_logger.rb b/app/models/clusters/concerns/kubernetes_logger.rb new file mode 100644 index 00000000000..2eca33a7610 --- /dev/null +++ b/app/models/clusters/concerns/kubernetes_logger.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Clusters + module Concerns + module KubernetesLogger + def logger + @logger ||= Gitlab::Kubernetes::Logger.build + end + + def log_exception(error, event) + logger.error( + { + exception: error.class.name, + status_code: error.error_code, + cluster_id: cluster&.id, + application_id: id, + class_name: self.class.name, + event: event, + message: error.message + } + ) + + Gitlab::ErrorTracking.track_exception(error, cluster_id: cluster&.id, application_id: id) + end + end + end +end diff --git a/app/models/clusters/integrations/elastic_stack.rb b/app/models/clusters/integrations/elastic_stack.rb new file mode 100644 index 00000000000..565d268259a --- /dev/null +++ b/app/models/clusters/integrations/elastic_stack.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Clusters + module Integrations + class ElasticStack < ApplicationRecord + include ::Clusters::Concerns::ElasticsearchClient + include ::Clusters::Concerns::KubernetesLogger + + self.table_name = 'clusters_integration_elasticstack' + self.primary_key = :cluster_id + + belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id + + validates :cluster, presence: true + validates :enabled, inclusion: { in: [true, false] } + + def available? + enabled + end + + def service_name + chart_above_v3? ? 'elastic-stack-elasticsearch-master' : 'elastic-stack-elasticsearch-client' + end + + def chart_above_v2? + return true if chart_version.nil? + + Gem::Version.new(chart_version) >= Gem::Version.new('2.0.0') + end + + def chart_above_v3? + return true if chart_version.nil? + + Gem::Version.new(chart_version) >= Gem::Version.new('3.0.0') + end + end + end +end diff --git a/app/models/clusters/integrations/prometheus.rb b/app/models/clusters/integrations/prometheus.rb index 1496d8ff1dd..0a01ac5d1ce 100644 --- a/app/models/clusters/integrations/prometheus.rb +++ b/app/models/clusters/integrations/prometheus.rb @@ -4,6 +4,7 @@ module Clusters module Integrations class Prometheus < ApplicationRecord include ::Clusters::Concerns::PrometheusClient + include AfterCommitQueue self.table_name = 'clusters_integration_prometheus' self.primary_key = :cluster_id @@ -13,9 +14,46 @@ module Clusters validates :cluster, presence: true validates :enabled, inclusion: { in: [true, false] } + attr_encrypted :alert_manager_token, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm' + + default_value_for(:alert_manager_token) { SecureRandom.hex } + + after_destroy do + run_after_commit do + deactivate_project_services + end + end + + after_save do + next unless enabled_before_last_save != enabled + + run_after_commit do + if enabled + activate_project_services + else + deactivate_project_services + end + end + end + def available? enabled? end + + private + + def activate_project_services + ::Clusters::Applications::ActivateServiceWorker + .perform_async(cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass + end + + def deactivate_project_services + ::Clusters::Applications::DeactivateServiceWorker + .perform_async(cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass + end end end end diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb index bfd01775620..af2eba42721 100644 --- a/app/models/clusters/providers/aws.rb +++ b/app/models/clusters/providers/aws.rb @@ -18,7 +18,7 @@ module Clusters attr_encrypted :secret_access_key, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' validates :role_arn, diff --git a/app/models/commit.rb b/app/models/commit.rb index 5c3e3685c64..09e43bb8f20 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -142,6 +142,7 @@ class Commit delegate \ :pipelines, :last_pipeline, + :lazy_latest_pipeline, :latest_pipeline, :latest_pipeline_for_project, :set_latest_pipeline_for_ref, diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index e989129209a..c5ba19438cd 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -214,8 +214,14 @@ class CommitStatus < ApplicationRecord allow_failure? && (failed? || canceled?) end + # Time spent running. def duration - calculate_duration + calculate_duration(started_at, finished_at) + end + + # Time spent in the pending state. + def queued_duration + calculate_duration(queued_at, started_at) end def latest? @@ -286,4 +292,4 @@ class CommitStatus < ApplicationRecord end end -CommitStatus.prepend_if_ee('::EE::CommitStatus') +CommitStatus.prepend_mod_with('CommitStatus') diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb index f1c39dda49d..90d48aa81d0 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -27,6 +27,7 @@ module Analytics scope :default_stages, -> { where(custom: false) } scope :ordered, -> { order(:relative_position, :id) } scope :for_list, -> { includes(:start_event_label, :end_event_label).ordered } + scope :by_value_stream, -> (value_stream) { where(value_stream_id: value_stream.id) } end def parent=(_) diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index bbf9ecbcfe9..80cf6260b0b 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -214,9 +214,9 @@ module AtomicInternalId def self.project_init(klass, column_name = :iid) ->(instance, scope) do if instance - klass.where(project_id: instance.project_id).maximum(column_name) + klass.default_scoped.where(project_id: instance.project_id).maximum(column_name) elsif scope.present? - klass.where(**scope).maximum(column_name) + klass.default_scoped.where(**scope).maximum(column_name) end end end diff --git a/app/models/concerns/board_recent_visit.rb b/app/models/concerns/board_recent_visit.rb new file mode 100644 index 00000000000..fd4d574ac58 --- /dev/null +++ b/app/models/concerns/board_recent_visit.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module BoardRecentVisit + extend ActiveSupport::Concern + + class_methods do + def visited!(user, board) + find_or_create_by( + "user" => user, + board_parent_relation => board.resource_parent, + board_relation => board + ).tap do |visit| + visit.touch + end + rescue ActiveRecord::RecordNotUnique + retry + end + + def latest(user, parent, count: nil) + visits = by_user_parent(user, parent).order(updated_at: :desc) + visits = visits.preload(board_relation) + + visits.first(count) + end + + def board_relation + :board + end + + def board_parent_relation + raise NotImplementedError + end + end +end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 34c1b6d25a4..a5cf947ba07 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -26,7 +26,7 @@ module CacheMarkdownField # Returns the default Banzai render context for the cached markdown field. def banzai_render_context(field) - raise ArgumentError.new("Unknown field: #{field.inspect}") unless + raise ArgumentError, "Unknown field: #{field.inspect}" unless cached_markdown_fields.markdown_fields.include?(field) # Always include a project key, or Banzai complains @@ -99,7 +99,7 @@ module CacheMarkdownField end def cached_html_for(markdown_field) - raise ArgumentError.new("Unknown field: #{markdown_field}") unless + raise ArgumentError, "Unknown field: #{markdown_field}" unless cached_markdown_fields.markdown_fields.include?(markdown_field) __send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb index ee56322cce7..f3b47047c55 100644 --- a/app/models/concerns/cacheable_attributes.rb +++ b/app/models/concerns/cacheable_attributes.rb @@ -53,7 +53,7 @@ module CacheableAttributes return cached_record if cached_record.present? current_without_cache.tap { |current_record| current_record&.cache! } - rescue => e + rescue StandardError => e if Rails.env.production? Gitlab::AppLogger.warn("Cached record for #{name} couldn't be loaded, falling back to uncached record: #{e}") else @@ -66,7 +66,7 @@ module CacheableAttributes def expire Gitlab::SafeRequestStore.delete(request_store_cache_key) cache_backend.delete(cache_key) - rescue + rescue StandardError # Gracefully handle when Redis is not available. For example, # omnibus may fail here during gitlab:assets:compile. end diff --git a/app/models/concerns/cascading_namespace_setting_attribute.rb b/app/models/concerns/cascading_namespace_setting_attribute.rb index 2b4a108a9a0..9efd90756b1 100644 --- a/app/models/concerns/cascading_namespace_setting_attribute.rb +++ b/app/models/concerns/cascading_namespace_setting_attribute.rb @@ -55,6 +55,7 @@ module CascadingNamespaceSettingAttribute # public methods define_attr_reader(attribute) define_attr_writer(attribute) + define_lock_attr_writer(attribute) define_lock_methods(attribute) alias_boolean(attribute) @@ -84,7 +85,7 @@ module CascadingNamespaceSettingAttribute next self[attribute] unless self.class.cascading_settings_feature_enabled? next self[attribute] if will_save_change_to_attribute?(attribute) - next locked_value(attribute) if cascading_attribute_locked?(attribute) + next locked_value(attribute) if cascading_attribute_locked?(attribute, include_self: false) next self[attribute] unless self[attribute].nil? cascaded_value = cascaded_ancestor_value(attribute) @@ -97,15 +98,25 @@ module CascadingNamespaceSettingAttribute def define_attr_writer(attribute) define_method("#{attribute}=") do |value| + return value if value == cascaded_ancestor_value(attribute) + clear_memoization(attribute) + super(value) + end + end + + def define_lock_attr_writer(attribute) + define_method("lock_#{attribute}=") do |value| + attr_value = public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend + write_attribute(attribute, attr_value) if self[attribute].nil? super(value) end end def define_lock_methods(attribute) - define_method("#{attribute}_locked?") do - cascading_attribute_locked?(attribute) + define_method("#{attribute}_locked?") do |include_self: false| + cascading_attribute_locked?(attribute, include_self: include_self) end define_method("#{attribute}_locked_by_ancestor?") do @@ -133,7 +144,7 @@ module CascadingNamespaceSettingAttribute def define_validator_methods(attribute) define_method("#{attribute}_changeable?") do return unless cascading_attribute_changed?(attribute) - return unless cascading_attribute_locked?(attribute) + return unless cascading_attribute_locked?(attribute, include_self: false) errors.add(attribute, s_('CascadingSettings|cannot be changed because it is locked by an ancestor')) end @@ -141,7 +152,7 @@ module CascadingNamespaceSettingAttribute define_method("lock_#{attribute}_changeable?") do return unless cascading_attribute_changed?("lock_#{attribute}") - if cascading_attribute_locked?(attribute) + if cascading_attribute_locked?(attribute, include_self: false) return errors.add(:"lock_#{attribute}", s_('CascadingSettings|cannot be changed because it is locked by an ancestor')) end @@ -202,8 +213,9 @@ module CascadingNamespaceSettingAttribute Gitlab::CurrentSettings.public_send("lock_#{attribute}") # rubocop:disable GitlabSecurity/PublicSend end - def cascading_attribute_locked?(attribute) - locked_by_ancestor?(attribute) || locked_by_application_setting?(attribute) + def cascading_attribute_locked?(attribute, include_self:) + locked_by_self = include_self ? public_send("lock_#{attribute}?") : false # rubocop:disable GitlabSecurity/PublicSend + locked_by_self || locked_by_ancestor?(attribute) || locked_by_application_setting?(attribute) end def cascading_attribute_changed?(attribute) diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb index 0d29955268f..27040a677ff 100644 --- a/app/models/concerns/ci/artifactable.rb +++ b/app/models/concerns/ci/artifactable.rb @@ -43,4 +43,4 @@ module Ci end end -Ci::Artifactable.prepend_ee_mod +Ci::Artifactable.prepend_mod diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index c990da5873a..f3c254053b5 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -122,12 +122,10 @@ module Ci private - def calculate_duration - if started_at && finished_at - finished_at - started_at - elsif started_at - Time.current - started_at - end + def calculate_duration(start_time, end_time) + return unless start_time + + (end_time || Time.current) - start_time end end end diff --git a/app/models/concerns/ci/maskable.rb b/app/models/concerns/ci/maskable.rb index 4e0ee72f18f..e1ef4531845 100644 --- a/app/models/concerns/ci/maskable.rb +++ b/app/models/concerns/ci/maskable.rb @@ -9,9 +9,9 @@ module Ci # * No variables # * No spaces # * Minimal length of 8 characters - # * Characters must be from the Base64 alphabet (RFC4648) with the addition of '@', ':' and '.' + # * Characters must be from the Base64 alphabet (RFC4648) with the addition of '@', ':', '.', and '~' # * Absolutely no fun is allowed - REGEX = /\A[a-zA-Z0-9_+=\/@:.-]{8,}\z/.freeze + REGEX = /\A[a-zA-Z0-9_+=\/@:.~-]{8,}\z/.freeze included do validates :masked, inclusion: { in: [true, false] } diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index 26e644646b4..601637ea32a 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -88,4 +88,4 @@ module Ci end end -Ci::Metadatable.prepend_if_ee('EE::Ci::Metadatable') +Ci::Metadatable.prepend_mod_with('Ci::Metadatable') diff --git a/app/models/concerns/cron_schedulable.rb b/app/models/concerns/cron_schedulable.rb new file mode 100644 index 00000000000..beb3a09c119 --- /dev/null +++ b/app/models/concerns/cron_schedulable.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module CronSchedulable + extend ActiveSupport::Concern + include Schedulable + + ## + # The `next_run_at` column is set to the actual execution date of worker that + # triggers the schedule. This way, a schedule like `*/1 * * * *` won't be triggered + # in a short interval when the worker runs irregularly by Sidekiq Memory Killer. + def set_next_run_at + now = Time.zone.now + ideal_next_run = ideal_next_run_from(now) + + self.next_run_at = if ideal_next_run == cron_worker_next_run_from(now) + ideal_next_run + else + cron_worker_next_run_from(ideal_next_run) + end + end + + private + + def ideal_next_run_from(start_time) + next_time_from(start_time, cron, cron_timezone) + end + + def cron_worker_next_run_from(start_time) + next_time_from(start_time, worker_cron_expression, Time.zone.name) + end + + def next_time_from(start_time, cron, cron_timezone) + Gitlab::Ci::CronParser + .new(cron, cron_timezone) + .next_time_from(start_time) + end + + def worker_cron_expression + raise NotImplementedError + end +end diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb index de17f50cd29..2e368b12cb7 100644 --- a/app/models/concerns/enums/ci/commit_status.rb +++ b/app/models/concerns/enums/ci/commit_status.rb @@ -22,6 +22,8 @@ module Enums forward_deployment_failure: 13, user_blocked: 14, project_deleted: 15, + ci_quota_exceeded: 16, + pipeline_loop_detected: 17, insufficient_bridge_permissions: 1_001, downstream_bridge_project_not_found: 1_002, invalid_bridge_trigger: 1_003, @@ -35,4 +37,4 @@ module Enums end end -Enums::Ci::CommitStatus.prepend_if_ee('EE::Enums::Ci::CommitStatus') +Enums::Ci::CommitStatus.prepend_mod_with('Enums::Ci::CommitStatus') diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb index fdc48d09db2..c42b046592f 100644 --- a/app/models/concerns/enums/ci/pipeline.rb +++ b/app/models/concerns/enums/ci/pipeline.rb @@ -10,6 +10,7 @@ module Enums unknown_failure: 0, config_error: 1, external_validation_failure: 2, + user_not_verified: 3, activity_limit_exceeded: 20, size_limit_exceeded: 21, job_activity_limit_exceeded: 22, diff --git a/app/models/concerns/enums/internal_id.rb b/app/models/concerns/enums/internal_id.rb index b08c05b1934..71c86bab136 100644 --- a/app/models/concerns/enums/internal_id.rb +++ b/app/models/concerns/enums/internal_id.rb @@ -22,4 +22,4 @@ module Enums end end -Enums::InternalId.prepend_if_ee('EE::Enums::InternalId') +Enums::InternalId.prepend_mod_with('Enums::InternalId') diff --git a/app/models/concerns/enums/vulnerability.rb b/app/models/concerns/enums/vulnerability.rb index 4b2e9e9e0b2..55360eb92e6 100644 --- a/app/models/concerns/enums/vulnerability.rb +++ b/app/models/concerns/enums/vulnerability.rb @@ -43,4 +43,4 @@ module Enums end end -Enums::Vulnerability.prepend_if_ee('EE::Enums::Vulnerability') +Enums::Vulnerability.prepend_mod_with('Enums::Vulnerability') diff --git a/app/models/concerns/from_set_operator.rb b/app/models/concerns/from_set_operator.rb index 593fd251c5c..c6d63631c84 100644 --- a/app/models/concerns/from_set_operator.rb +++ b/app/models/concerns/from_set_operator.rb @@ -10,8 +10,8 @@ module FromSetOperator raise "Trying to redefine method '#{method(method_name)}'" if methods.include?(method_name) - define_method(method_name) do |members, remove_duplicates: true, alias_as: table_name| - operator_sql = operator.new(members, remove_duplicates: remove_duplicates).to_sql + define_method(method_name) do |members, remove_duplicates: true, remove_order: true, alias_as: table_name| + operator_sql = operator.new(members, remove_duplicates: remove_duplicates, remove_order: remove_order).to_sql from(Arel.sql("(#{operator_sql}) #{alias_as}")) end diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb index 67953105bed..b376537a418 100644 --- a/app/models/concerns/group_descendant.rb +++ b/app/models/concerns/group_descendant.rb @@ -22,7 +22,7 @@ module GroupDescendant return [] if descendants.empty? unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) } - raise ArgumentError.new(_('element is not a hierarchy')) + raise ArgumentError, _('element is not a hierarchy') end all_hierarchies = descendants.map do |descendant| @@ -56,7 +56,7 @@ module GroupDescendant end if parent.nil? && hierarchy_top.present? - raise ArgumentError.new(_('specified top is not part of the tree')) + raise ArgumentError, _('specified top is not part of the tree') end if parent && parent != hierarchy_top diff --git a/app/models/concerns/integration.rb b/app/models/concerns/has_integrations.rb index 5e53f13be95..b2775f4cbb2 100644 --- a/app/models/concerns/integration.rb +++ b/app/models/concerns/has_integrations.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -module Integration +module HasIntegrations extend ActiveSupport::Concern class_methods do def with_custom_integration_for(integration, page = nil, per = nil) - custom_integration_project_ids = Service + custom_integration_project_ids = Integration .select(:project_id) .where(type: integration.type) .where(inherit_from_id: nil) @@ -17,13 +17,13 @@ module Integration end def without_integration(integration) - services = Service + integrations = Integration .select('1') .where('services.project_id = projects.id') .where(type: integration.type) Project - .where('NOT EXISTS (?)', services) + .where('NOT EXISTS (?)', integrations) .where(pending_delete: false) .where(archived: false) end diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index 774cda2c3e8..33f6904bc91 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -17,7 +17,7 @@ module HasRepository def valid_repo? repository.exists? - rescue + rescue StandardError errors.add(:base, _('Invalid repository path')) false end @@ -25,7 +25,7 @@ module HasRepository def repo_exists? strong_memoize(:repo_exists) do repository.exists? - rescue + rescue StandardError false end end diff --git a/app/models/concerns/has_timelogs_report.rb b/app/models/concerns/has_timelogs_report.rb index 90f9876de95..3af063438bf 100644 --- a/app/models/concerns/has_timelogs_report.rb +++ b/app/models/concerns/has_timelogs_report.rb @@ -15,6 +15,6 @@ module HasTimelogsReport private def timelogs_for(start_time, end_time) - Timelog.between_times(start_time, end_time).for_issues_in_group(self) + Timelog.between_times(start_time, end_time).in_group(self) end end diff --git a/app/models/concerns/has_wiki_page_meta_attributes.rb b/app/models/concerns/has_wiki_page_meta_attributes.rb index 136f2d00ce3..55681bc91a5 100644 --- a/app/models/concerns/has_wiki_page_meta_attributes.rb +++ b/app/models/concerns/has_wiki_page_meta_attributes.rb @@ -59,7 +59,7 @@ module HasWikiPageMetaAttributes if conflict.present? meta.errors.add(:canonical_slug, 'Duplicate value found') - raise CanonicalSlugConflictError.new(meta) + raise CanonicalSlugConflictError, meta end meta diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 1e44321e148..f5c70f10dc5 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -63,7 +63,7 @@ module Issuable has_many :note_authors, -> { distinct }, through: :notes, source: :author - has_many :label_links, as: :target, dependent: :destroy, inverse_of: :target # rubocop:disable Cop/ActiveRecordDependent + has_many :label_links, as: :target, inverse_of: :target has_many :labels, through: :label_links has_many :todos, as: :target @@ -103,7 +103,7 @@ module Issuable end scope :assigned_to, ->(u) do assignees_table = Arel::Table.new("#{to_ability_name}_assignees") - sql = assignees_table.project('true').where(assignees_table[:user_id].in(u)).where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) + sql = assignees_table.project('true').where(assignees_table[:user_id].in(u.id)).where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) where("EXISTS (#{sql.to_sql})") end # rubocop:enable GitlabSecurity/SqlInjection @@ -564,4 +564,4 @@ module Issuable end end -Issuable.prepend_if_ee('EE::Issuable') +Issuable.prepend_mod_with('Issuable') diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb index a5ffa959174..28d12a033a6 100644 --- a/app/models/concerns/issue_available_features.rb +++ b/app/models/concerns/issue_available_features.rb @@ -29,5 +29,5 @@ module IssueAvailableFeatures end end -IssueAvailableFeatures.prepend_if_ee('EE::IssueAvailableFeatures') -IssueAvailableFeatures::ClassMethods.prepend_if_ee('EE::IssueAvailableFeatures::ClassMethods') +IssueAvailableFeatures.prepend_mod_with('IssueAvailableFeatures') +IssueAvailableFeatures::ClassMethods.prepend_mod_with('IssueAvailableFeatures::ClassMethods') diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb index 3cb0bd85936..672bcdbbb1b 100644 --- a/app/models/concerns/limitable.rb +++ b/app/models/concerns/limitable.rb @@ -7,6 +7,7 @@ module Limitable included do class_attribute :limit_scope class_attribute :limit_name + class_attribute :limit_feature_flag self.limit_name = self.name.demodulize.tableize validate :validate_plan_limit_not_exceeded, on: :create @@ -25,6 +26,7 @@ module Limitable def validate_scoped_plan_limit_not_exceeded scope_relation = self.public_send(limit_scope) # rubocop:disable GitlabSecurity/PublicSend return unless scope_relation + return if limit_feature_flag && ::Feature.disabled?(limit_feature_flag, scope_relation, default_enabled: :yaml) relation = self.class.where(limit_scope => scope_relation) limits = scope_relation.actual_limits diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb index 59e0ed75d2d..848ef63f1c2 100644 --- a/app/models/concerns/loaded_in_group_list.rb +++ b/app/models/concerns/loaded_in_group_list.rb @@ -79,4 +79,4 @@ module LoadedInGroupList end end -LoadedInGroupList::ClassMethods.prepend_if_ee('EE::LoadedInGroupList::ClassMethods') +LoadedInGroupList::ClassMethods.prepend_mod_with('LoadedInGroupList::ClassMethods') diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 5db077c178d..f1baa923ec5 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -211,4 +211,4 @@ module Mentionable end end -Mentionable.prepend_if_ee('EE::Mentionable') +Mentionable.prepend_mod_with('Mentionable') diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb index 5a5ce1809d0..e33b6db0103 100644 --- a/app/models/concerns/mentionable/reference_regexes.rb +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -37,4 +37,4 @@ module Mentionable end end -Mentionable::ReferenceRegexes.prepend_if_ee('EE::Mentionable::ReferenceRegexes') +Mentionable::ReferenceRegexes.prepend_mod_with('Mentionable::ReferenceRegexes') diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index d42417bb6c1..c4f810ab9b1 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -28,7 +28,7 @@ module Milestoneable scope :without_release, -> do joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id") - .where('milestone_releases.release_id IS NULL') + .where(milestone_releases: { release_id: nil }) end scope :joins_milestone_releases, -> do @@ -57,4 +57,4 @@ module Milestoneable end end -Milestoneable.prepend_if_ee('EE::Milestoneable') +Milestoneable.prepend_mod_with('Milestoneable') diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index f3cc68e4b85..f6d4e5bd27b 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -183,5 +183,5 @@ end Noteable.extend(Noteable::ClassMethods) -Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods') -Noteable.prepend_if_ee('EE::Noteable') +Noteable::ClassMethods.prepend_mod_with('Noteable::ClassMethods') +Noteable.prepend_mod_with('Noteable') diff --git a/app/models/concerns/optimized_issuable_label_filter.rb b/app/models/concerns/optimized_issuable_label_filter.rb index c7af841e450..19d2ac620f3 100644 --- a/app/models/concerns/optimized_issuable_label_filter.rb +++ b/app/models/concerns/optimized_issuable_label_filter.rb @@ -28,7 +28,6 @@ module OptimizedIssuableLabelFilter # Taken from IssuableFinder def count_by_state - return super if root_namespace.nil? return super if Feature.disabled?(:optimized_issuable_label_filter, default_enabled: :yaml) count_params = params.merge(state: nil, sort: nil, force_cte: true) @@ -40,7 +39,11 @@ module OptimizedIssuableLabelFilter .group(:state_id) .count - counts = state_counts.transform_keys { |key| count_key(key) } + counts = Hash.new(0) + + state_counts.each do |key, value| + counts[count_key(key)] += value + end counts[:all] = counts.values.sum counts.with_indifferent_access diff --git a/app/models/concerns/packages/debian/architecture.rb b/app/models/concerns/packages/debian/architecture.rb index 760ebb49980..e2fa0ceb0f6 100644 --- a/app/models/concerns/packages/debian/architecture.rb +++ b/app/models/concerns/packages/debian/architecture.rb @@ -23,6 +23,7 @@ module Packages uniqueness: { scope: %i[distribution_id] }, format: { with: Gitlab::Regex.debian_architecture_regex } + scope :ordered_by_name, -> { order(:name) } scope :with_distribution, ->(distribution) { where(distribution: distribution) } scope :with_name, ->(name) { where(name: name) } end diff --git a/app/models/concerns/packages/debian/component.rb b/app/models/concerns/packages/debian/component.rb index 7b342c7b684..5ea686faec2 100644 --- a/app/models/concerns/packages/debian/component.rb +++ b/app/models/concerns/packages/debian/component.rb @@ -23,6 +23,7 @@ module Packages uniqueness: { scope: %i[distribution_id] }, format: { with: Gitlab::Regex.debian_component_regex } + scope :ordered_by_name, -> { order(:name) } scope :with_distribution, ->(distribution) { where(distribution: distribution) } scope :with_name, ->(name) { where(name: name) } end diff --git a/app/models/concerns/packages/debian/component_file.rb b/app/models/concerns/packages/debian/component_file.rb index 3cc2c291e96..c41635a0d16 100644 --- a/app/models/concerns/packages/debian/component_file.rb +++ b/app/models/concerns/packages/debian/component_file.rb @@ -60,6 +60,8 @@ module Packages scope :preload_distribution, -> { includes(component: :distribution) } + scope :created_before, ->(reference) { where("#{table_name}.created_at < ?", reference) } + mount_file_store_uploader Packages::Debian::ComponentFileUploader before_validation :update_size_from_file diff --git a/app/models/concerns/packages/debian/distribution.rb b/app/models/concerns/packages/debian/distribution.rb index 08fb9ccf3ea..267c7a4d201 100644 --- a/app/models/concerns/packages/debian/distribution.rb +++ b/app/models/concerns/packages/debian/distribution.rb @@ -84,7 +84,7 @@ module Packages attr_encrypted :signing_keys, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: false, encode_iv: false diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index acd654bd229..25410a859e9 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -135,4 +135,4 @@ module Participable end end -Participable.prepend_if_ee('EE::Participable') +Participable.prepend_mod_with('Participable') diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 7c774d8bad7..484c91e0833 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -109,4 +109,4 @@ module ProjectFeaturesCompatibility end end -ProjectFeaturesCompatibility.prepend_if_ee('EE::ProjectFeaturesCompatibility') +ProjectFeaturesCompatibility.prepend_mod_with('ProjectFeaturesCompatibility') diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index 55c2bf96a94..afebc426762 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -26,9 +26,14 @@ module PrometheusAdapter } end + # Overridden in app/models/clusters/applications/prometheus.rb + def managed_prometheus? + false + end + # This is a light-weight check if a prometheus client is properly configured. def configured? - raise NotImplemented + raise NotImplementedError end # This is a heavy-weight check if a prometheus is properly configured and accessible from GitLab. diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index 2828ae4a3a9..ec56f4a32af 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -78,4 +78,4 @@ end # since these are defined in a ClassMethods constant. As such, we prepend the # module directly into ProtectedRef::ClassMethods, instead of prepending it into # ProtectedRef. -ProtectedRef::ClassMethods.prepend_if_ee('EE::ProtectedRef') +ProtectedRef::ClassMethods.prepend_mod_with('ProtectedRef') diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index 5e38ce7cad8..618ad96905d 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -53,12 +53,12 @@ module ProtectedRefAccess end end -ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes') -ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess') +ProtectedRefAccess.include_mod_with('ProtectedRefAccess::Scopes') +ProtectedRefAccess.prepend_mod_with('ProtectedRefAccess') # When using `prepend` (or `include` for that matter), the `ClassMethods` # constants are not merged. This means that `class_methods` in # `EE::ProtectedRefAccess` would be ignored. # # To work around this, we prepend the `ClassMethods` constant manually. -ProtectedRefAccess::ClassMethods.prepend_if_ee('EE::ProtectedRefAccess::ClassMethods') +ProtectedRefAccess::ClassMethods.prepend_mod_with('ProtectedRefAccess::ClassMethods') diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index dbc70ac2218..9ed2070d11c 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -168,7 +168,7 @@ module ReactiveCaching data_deep_size = Gitlab::Utils::DeepSize.new(data, max_size: self.class.reactive_cache_hard_limit) - raise ExceededReactiveCacheLimit.new unless data_deep_size.valid? + raise ExceededReactiveCacheLimit unless data_deep_size.valid? end end end diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index 7f559f0a7ed..75dfed6d58f 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -53,13 +53,13 @@ module RelativePositioning return [size, starting_from] if size >= MIN_GAP + terminus = context.at_position(starting_from) + if at_end - terminus = context.max_sibling terminus.shift_left max_relative_position = terminus.relative_position [[(MAX_POSITION - max_relative_position) / gaps, IDEAL_DISTANCE].min, max_relative_position] else - terminus = context.min_sibling terminus.shift_right min_relative_position = terminus.relative_position [[(min_relative_position - MIN_POSITION) / gaps, IDEAL_DISTANCE].min, min_relative_position] @@ -79,6 +79,8 @@ module RelativePositioning objects = objects.reject(&:relative_position) return 0 if objects.empty? + objects.first.check_repositioning_allowed! + number_of_gaps = objects.size # 1 to the nearest neighbour, and one between each representative = RelativePositioning.mover.context(objects.first) @@ -123,6 +125,12 @@ module RelativePositioning ::Gitlab::RelativePositioning::Mover.new(START_POSITION, (MIN_POSITION..MAX_POSITION)) end + # To be overriden on child classes whenever + # blocking position updates is necessary. + def check_repositioning_allowed! + nil + end + def move_between(before, after) before, after = [before, after].sort_by(&:relative_position) if before && after diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb index 8607f0d94f4..1dd8eebeff3 100644 --- a/app/models/concerns/repository_storage_movable.rb +++ b/app/models/concerns/repository_storage_movable.rb @@ -50,7 +50,7 @@ module RepositoryStorageMovable begin storage_move.container.set_repository_read_only!(skip_git_transfer_check: true) - rescue => err + rescue StandardError => err storage_move.add_error(err.message) next false end @@ -114,7 +114,7 @@ module RepositoryStorageMovable private def container_repository_writable - add_error(_('is read only')) if container&.repository_read_only? + add_error(_('is read-only')) if container&.repository_read_only? end def error_key diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 71d8e06de76..847abdc1b6d 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -96,11 +96,49 @@ module Routable end def full_name - route&.name || build_full_name + # We have to test for persistence as the cache key uses #updated_at + return (route&.name || build_full_name) unless persisted? && Feature.enabled?(:cached_route_lookups, self, type: :ops, default_enabled: :yaml) + + # Return the name as-is if the parent is missing + return name if route.nil? && parent.nil? && name.present? + + # If the route is already preloaded, return directly, preventing an extra load + return route.name if route_loaded? && route.present? + + # Similarly, we can allow the build if the parent is loaded + return build_full_name if parent_loaded? + + Gitlab::Cache.fetch_once([cache_key, :full_name]) do + route&.name || build_full_name + end end def full_path - route&.path || build_full_path + # We have to test for persistence as the cache key uses #updated_at + return (route&.path || build_full_path) unless persisted? && Feature.enabled?(:cached_route_lookups, self, type: :ops, default_enabled: :yaml) + + # Return the path as-is if the parent is missing + return path if route.nil? && parent.nil? && path.present? + + # If the route is already preloaded, return directly, preventing an extra load + return route.path if route_loaded? && route.present? + + # Similarly, we can allow the build if the parent is loaded + return build_full_path if parent_loaded? + + Gitlab::Cache.fetch_once([cache_key, :full_path]) do + route&.path || build_full_path + end + end + + # Overriden in the Project model + # parent_id condition prevents issues with parent reassignment + def parent_loaded? + association(:parent).loaded? + end + + def route_loaded? + association(:route).loaded? end def full_path_components @@ -124,7 +162,9 @@ module Routable def set_path_errors route_path_errors = self.errors.delete(:"route.path") - self.errors[:path].concat(route_path_errors) if route_path_errors + route_path_errors&.each do |msg| + self.errors.add(:path, msg) + end end def full_name_changed? diff --git a/app/models/concerns/services/data_fields.rb b/app/models/concerns/services/data_fields.rb index 10963e4e7d8..fd56af449bc 100644 --- a/app/models/concerns/services/data_fields.rb +++ b/app/models/concerns/services/data_fields.rb @@ -5,11 +5,11 @@ module Services extend ActiveSupport::Concern included do - belongs_to :service + belongs_to :integration, inverse_of: self.name.underscore.to_sym, foreign_key: :service_id - delegate :activated?, to: :service, allow_nil: true + delegate :activated?, to: :integration, allow_nil: true - validates :service, presence: true + validates :integration, presence: true end class_methods do diff --git a/app/models/concerns/sha256_attribute.rb b/app/models/concerns/sha256_attribute.rb index 9dfe1b77829..4921f7f1a7e 100644 --- a/app/models/concerns/sha256_attribute.rb +++ b/app/models/concerns/sha256_attribute.rb @@ -31,9 +31,9 @@ module Sha256Attribute end unless column.type == :binary - raise ArgumentError.new("sha256_attribute #{name.inspect} is invalid since the column type is not :binary") + raise ArgumentError, "sha256_attribute #{name.inspect} is invalid since the column type is not :binary" end - rescue => error + rescue StandardError => error Gitlab::AppLogger.error "Sha256Attribute initialization: #{error.message}" raise end diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index cbac6a210c7..f6f5dbce4b6 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -24,9 +24,9 @@ module ShaAttribute return unless column unless column.type == :binary - raise ArgumentError.new("sha_attribute #{name.inspect} is invalid since the column type is not :binary") + raise ArgumentError, "sha_attribute #{name.inspect} is invalid since the column type is not :binary" end - rescue => error + rescue StandardError => error Gitlab::AppLogger.error "ShaAttribute initialization: #{error.message}" raise end @@ -37,4 +37,4 @@ module ShaAttribute end end -ShaAttribute::ClassMethods.prepend_if_ee('EE::ShaAttribute') +ShaAttribute::ClassMethods.prepend_mod_with('ShaAttribute') diff --git a/app/models/concerns/sidebars/container_with_html_options.rb b/app/models/concerns/sidebars/container_with_html_options.rb deleted file mode 100644 index 12ea366c66a..00000000000 --- a/app/models/concerns/sidebars/container_with_html_options.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module ContainerWithHtmlOptions - # The attributes returned from this method - # will be applied to helper methods like - # `link_to` or the div containing the container. - def container_html_options - { - aria: { label: title } - }.merge(extra_container_html_options) - end - - # Classes will override mostly this method - # and not `container_html_options`. - def extra_container_html_options - {} - end - - # Attributes to pass to the html_options attribute - # in the helper method that sets the active class - # on each element. - def nav_link_html_options - {} - end - - def title - raise NotImplementedError - end - - # The attributes returned from this method - # will be applied right next to the title, - # for example in the span that renders the title. - def title_html_options - {} - end - - def link - raise NotImplementedError - end - end -end diff --git a/app/models/concerns/sidebars/has_active_routes.rb b/app/models/concerns/sidebars/has_active_routes.rb deleted file mode 100644 index e7a153f067a..00000000000 --- a/app/models/concerns/sidebars/has_active_routes.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module HasActiveRoutes - # This method will indicate for which paths or - # controllers, the menu or menu item should - # be set as active. - # - # The returned values are passed to the `nav_link` helper method, - # so the params can be either `path`, `page`, `controller`. - # Param 'action' is not supported. - def active_routes - {} - end - end -end diff --git a/app/models/concerns/sidebars/has_hint.rb b/app/models/concerns/sidebars/has_hint.rb deleted file mode 100644 index 21dca39dca0..00000000000 --- a/app/models/concerns/sidebars/has_hint.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# This module has the necessary methods to store -# hints for menus. Hints are elements displayed -# when the user hover the menu item. -module Sidebars - module HasHint - def show_hint? - false - end - - def hint_html_options - {} - end - end -end diff --git a/app/models/concerns/sidebars/has_icon.rb b/app/models/concerns/sidebars/has_icon.rb deleted file mode 100644 index d1a87918285..00000000000 --- a/app/models/concerns/sidebars/has_icon.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -# This module has the necessary methods to show -# sprites or images next to the menu item. -module Sidebars - module HasIcon - def sprite_icon - nil - end - - def sprite_icon_html_options - {} - end - - def image_path - nil - end - - def image_html_options - {} - end - - def icon_or_image? - sprite_icon || image_path - end - end -end diff --git a/app/models/concerns/sidebars/has_pill.rb b/app/models/concerns/sidebars/has_pill.rb deleted file mode 100644 index ad7064fe63d..00000000000 --- a/app/models/concerns/sidebars/has_pill.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -# This module introduces the logic to show the "pill" element -# next to the menu item, indicating the a count. -module Sidebars - module HasPill - def has_pill? - false - end - - # In this method we will need to provide the query - # to retrieve the elements count - def pill_count - raise NotImplementedError - end - - def pill_html_options - {} - end - end -end diff --git a/app/models/concerns/sidebars/positionable_list.rb b/app/models/concerns/sidebars/positionable_list.rb deleted file mode 100644 index 30830d547f3..00000000000 --- a/app/models/concerns/sidebars/positionable_list.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -# This module handles elements in a list. All elements -# must have a different class -module Sidebars - module PositionableList - def add_element(list, element) - list << element - end - - def insert_element_before(list, before_element, new_element) - index = index_of(list, before_element) - - if index - list.insert(index, new_element) - else - list.unshift(new_element) - end - end - - def insert_element_after(list, after_element, new_element) - index = index_of(list, after_element) - - if index - list.insert(index + 1, new_element) - else - add_element(list, new_element) - end - end - - private - - def index_of(list, element) - list.index { |e| e.is_a?(element) } - end - end -end diff --git a/app/models/concerns/sidebars/renderable.rb b/app/models/concerns/sidebars/renderable.rb deleted file mode 100644 index a3976af8515..00000000000 --- a/app/models/concerns/sidebars/renderable.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Renderable - # This method will control whether the menu or menu_item - # should be rendered. It will be overriden by specific - # classes. - def render? - true - end - end -end diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index a82cf338039..948190dfadf 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -10,7 +10,7 @@ module Storage proj_with_tags = first_project_with_container_registry_tags if proj_with_tags - raise Gitlab::UpdatePathError.new("Namespace #{name} (#{id}) cannot be moved because at least one project (e.g. #{proj_with_tags.name} (#{proj_with_tags.id})) has tags in container registry") + raise Gitlab::UpdatePathError, "Namespace #{name} (#{id}) cannot be moved because at least one project (e.g. #{proj_with_tags.name} (#{proj_with_tags.id})) has tags in container registry" end parent_was = if saved_change_to_parent? && parent_id_before_last_save.present? @@ -48,7 +48,7 @@ module Storage begin send_update_instructions write_projects_repository_config - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, full_path_before_last_save: full_path_before_last_save, full_path: full_path, @@ -83,7 +83,7 @@ module Storage # if we cannot move namespace directory we should rollback # db changes in order to prevent out of sync between db and fs - raise Gitlab::UpdatePathError.new('namespace directory cannot be moved') + raise Gitlab::UpdatePathError, 'namespace directory cannot be moved' end end end diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index d8867177059..4d1c1d44af7 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -12,7 +12,7 @@ module Taskable COMPLETED = 'completed' INCOMPLETE = 'incomplete' COMPLETE_PATTERN = /(\[[xX]\])/.freeze - INCOMPLETE_PATTERN = /(\[[\s]\])/.freeze + INCOMPLETE_PATTERN = /(\[\s\])/.freeze ITEM_PATTERN = %r{ ^ (?:(?:>\s{0,4})*) # optional blockquote characters diff --git a/app/models/concerns/throttled_touch.rb b/app/models/concerns/throttled_touch.rb index 797c46f6cc5..b5682abb229 100644 --- a/app/models/concerns/throttled_touch.rb +++ b/app/models/concerns/throttled_touch.rb @@ -6,7 +6,7 @@ module ThrottledTouch # The amount of time to wait before "touch" can update a record again. TOUCH_INTERVAL = 1.minute - def touch(*args) + def touch(*args, **kwargs) super if (Time.zone.now - updated_at) > TOUCH_INTERVAL end end diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index 8273059b30c..fb9a8cd312d 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -72,11 +72,7 @@ module Timebox groups = groups.compact if groups.is_a? Array groups = [] if groups.nil? - if Feature.enabled?(:optimized_timebox_queries, default_enabled: true) - from_union([where(project_id: projects), where(group_id: groups)], remove_duplicates: false) - else - where(project_id: projects).or(where(group_id: groups)) - end + from_union([where(project_id: projects), where(group_id: groups)], remove_duplicates: false) end # A timebox is within the timeframe (start_date, end_date) if it overlaps diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index 535cf25eb9d..34c8630bb90 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -12,7 +12,7 @@ module TokenAuthenticatable def add_authentication_token_field(token_field, options = {}) if token_authenticatable_fields.include?(token_field) - raise ArgumentError.new("#{token_field} already configured via add_authentication_token_field") + raise ArgumentError, "#{token_field} already configured via add_authentication_token_field" end token_authenticatable_fields.push(token_field) diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index db5df6c2c9f..8fe34632430 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -29,11 +29,11 @@ module TriggerableHooks callable_scopes = triggers.keys + [:all] return none unless callable_scopes.include?(trigger) - public_send(trigger) # rubocop:disable GitlabSecurity/PublicSend + executable.public_send(trigger) # rubocop:disable GitlabSecurity/PublicSend end def select_active(hooks_scope, data) - select do |hook| + executable.select do |hook| ActiveHookFilter.new(hook).matches?(hooks_scope, data) end end diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index cf50305faab..f0e5e010e70 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -4,4 +4,4 @@ module VulnerabilityFindingHelpers extend ActiveSupport::Concern end -VulnerabilityFindingHelpers.prepend_if_ee('EE::VulnerabilityFindingHelpers') +VulnerabilityFindingHelpers.prepend_mod_with('VulnerabilityFindingHelpers') diff --git a/app/models/concerns/vulnerability_finding_signature_helpers.rb b/app/models/concerns/vulnerability_finding_signature_helpers.rb index f57e3cb0bfb..f98c1e93aaf 100644 --- a/app/models/concerns/vulnerability_finding_signature_helpers.rb +++ b/app/models/concerns/vulnerability_finding_signature_helpers.rb @@ -4,4 +4,4 @@ module VulnerabilityFindingSignatureHelpers extend ActiveSupport::Concern end -VulnerabilityFindingSignatureHelpers.prepend_if_ee('EE::VulnerabilityFindingSignatureHelpers') +VulnerabilityFindingSignatureHelpers.prepend_mod_with('VulnerabilityFindingSignatureHelpers') diff --git a/app/models/concerns/x509_serial_number_attribute.rb b/app/models/concerns/x509_serial_number_attribute.rb index d2a5c736604..dbba80eff53 100644 --- a/app/models/concerns/x509_serial_number_attribute.rb +++ b/app/models/concerns/x509_serial_number_attribute.rb @@ -31,9 +31,9 @@ module X509SerialNumberAttribute end unless column.type == :binary - raise ArgumentError.new("x509_serial_number_attribute #{name.inspect} is invalid since the column type is not :binary") + raise ArgumentError, "x509_serial_number_attribute #{name.inspect} is invalid since the column type is not :binary" end - rescue => error + rescue StandardError => error Gitlab::AppLogger.error "X509SerialNumberAttribute initialization: #{error.message}" raise end diff --git a/app/models/container_registry/event.rb b/app/models/container_registry/event.rb index 109fda675a2..c1b865ae578 100644 --- a/app/models/container_registry/event.rb +++ b/app/models/container_registry/event.rb @@ -66,4 +66,4 @@ module ContainerRegistry end end -::ContainerRegistry::Event.prepend_if_ee('EE::ContainerRegistry::Event') +::ContainerRegistry::Event.prepend_mod_with('ContainerRegistry::Event') diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index e2bdf8ffce2..6e0d0e347c9 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -7,6 +7,7 @@ class ContainerRepository < ApplicationRecord include Sortable WAITING_CLEANUP_STATUSES = %i[cleanup_scheduled cleanup_unfinished].freeze + REQUIRING_CLEANUP_STATUSES = %i[cleanup_unscheduled cleanup_scheduled].freeze belongs_to :project @@ -31,6 +32,7 @@ class ContainerRepository < ApplicationRecord scope :for_project_id, ->(project_id) { where(project_id: project_id) } scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) } scope :waiting_for_cleanup, -> { where(expiration_policy_cleanup_status: WAITING_CLEANUP_STATUSES) } + scope :expiration_policy_started_at_nil_or_before, ->(timestamp) { where('expiration_policy_started_at < ? OR expiration_policy_started_at IS NULL', timestamp) } def self.exists_by_path?(path) where( @@ -39,6 +41,23 @@ class ContainerRepository < ApplicationRecord ).exists? end + def self.with_enabled_policy + joins("INNER JOIN container_expiration_policies ON container_repositories.project_id = container_expiration_policies.project_id") + .where(container_expiration_policies: { enabled: true }) + end + + def self.requiring_cleanup + where( + container_repositories: { expiration_policy_cleanup_status: REQUIRING_CLEANUP_STATUSES }, + project_id: ::ContainerExpirationPolicy.runnable_schedules + .select(:project_id) + ) + end + + def self.with_unfinished_cleanup + with_enabled_policy.cleanup_unfinished + end + # rubocop: disable CodeReuse/ServiceClass def registry @registry ||= begin @@ -140,4 +159,4 @@ class ContainerRepository < ApplicationRecord end end -ContainerRepository.prepend_if_ee('EE::ContainerRepository') +ContainerRepository.prepend_mod_with('ContainerRepository') diff --git a/app/models/context_commits_diff.rb b/app/models/context_commits_diff.rb new file mode 100644 index 00000000000..fe1a72b79f2 --- /dev/null +++ b/app/models/context_commits_diff.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class ContextCommitsDiff + include ActsAsPaginatedDiff + + attr_reader :merge_request + + def initialize(merge_request) + @merge_request = merge_request + end + + def empty? + commits.empty? + end + + def commits_count + merge_request.context_commits_count + end + + def diffs(diff_options = nil) + Gitlab::Diff::FileCollection::Compare.new( + self, + project: merge_request.project, + diff_options: diff_options, + diff_refs: diff_refs + ) + end + + def raw_diffs(options = {}) + compare.diffs(options.merge(paths: paths)) + end + + def diff_refs + Gitlab::Diff::DiffRefs.new( + base_sha: commits.last&.diff_refs&.base_sha, + head_sha: commits.first&.diff_refs&.head_sha + ) + end + + private + + def compare + @compare ||= + Gitlab::Git::Compare.new( + merge_request.project.repository.raw_repository, + commits.last&.diff_refs&.base_sha, + commits.first&.diff_refs&.head_sha + ) + end + + def commits + @commits ||= merge_request.project.repository.commits_by(oids: merge_request.recent_context_commits.map(&:id)) + end + + def paths + merge_request.merge_request_context_commit_diff_files.map(&:path) + end +end diff --git a/app/models/cycle_analytics/project_level_stage_adapter.rb b/app/models/cycle_analytics/project_level_stage_adapter.rb index dd4afa9b809..5538e93a39e 100644 --- a/app/models/cycle_analytics/project_level_stage_adapter.rb +++ b/app/models/cycle_analytics/project_level_stage_adapter.rb @@ -4,6 +4,8 @@ # compatible with the old value stream controller actions. module CycleAnalytics class ProjectLevelStageAdapter + ProjectLevelStage = Struct.new(:title, :description, :legend, :name, :project_median, keyword_init: true ) + def initialize(stage, options) @stage = stage @options = options @@ -13,7 +15,7 @@ module CycleAnalytics def as_json(serializer: AnalyticsStageSerializer) presenter = Analytics::CycleAnalytics::StagePresenter.new(stage) - serializer.new.represent(OpenStruct.new( + serializer.new.represent(ProjectLevelStage.new( title: presenter.title, description: presenter.description, legend: presenter.legend, diff --git a/app/models/deployment.rb b/app/models/deployment.rb index d3280403bfd..e2b25690323 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -32,8 +32,9 @@ class Deployment < ApplicationRecord delegate :kubernetes_namespace, to: :deployment_cluster, allow_nil: true scope :for_environment, -> (environment) { where(environment_id: environment) } - scope :for_environment_name, -> (name) do - joins(:environment).where(environments: { name: name }) + scope :for_environment_name, -> (project, name) do + where('deployments.environment_id = (?)', + Environment.select(:id).where(project: project, name: name).limit(1)) end scope :for_status, -> (status) { where(status: status) } @@ -87,7 +88,7 @@ class Deployment < ApplicationRecord after_transition any => :running do |deployment| deployment.run_after_commit do - Deployments::ExecuteHooksWorker.perform_async(id) + Deployments::HooksWorker.perform_async(deployment_id: id, status_changed_at: Time.current) end end @@ -100,7 +101,7 @@ class Deployment < ApplicationRecord after_transition any => FINISHED_STATUSES do |deployment| deployment.run_after_commit do - Deployments::ExecuteHooksWorker.perform_async(id) + Deployments::HooksWorker.perform_async(deployment_id: id, status_changed_at: Time.current) end end @@ -182,8 +183,8 @@ class Deployment < ApplicationRecord Commit.truncate_sha(sha) end - def execute_hooks - deployment_data = Gitlab::DataBuilder::Deployment.build(self) + def execute_hooks(status_changed_at) + deployment_data = Gitlab::DataBuilder::Deployment.build(self, status_changed_at) project.execute_hooks(deployment_data, :deployment_hooks) project.execute_services(deployment_data, :deployment_hooks) end @@ -347,4 +348,4 @@ class Deployment < ApplicationRecord end end -Deployment.prepend_if_ee('EE::Deployment') +Deployment.prepend_mod_with('Deployment') diff --git a/app/models/deployment_merge_request.rb b/app/models/deployment_merge_request.rb index 7949bd81605..b91785eeb57 100644 --- a/app/models/deployment_merge_request.rb +++ b/app/models/deployment_merge_request.rb @@ -12,7 +12,7 @@ class DeploymentMergeRequest < ApplicationRecord end def self.by_deployment_id(id) - where('deployments.id = ?', id) + where(deployments: { id: id }) end def self.deployed_to(name) @@ -20,7 +20,7 @@ class DeploymentMergeRequest < ApplicationRecord # (project_id, name), instead of using the index on # (name varchar_pattern_ops). This results in better performance on # GitLab.com. - where('environments.name = ?', name) + where(environments: { name: name }) .where('environments.project_id = merge_requests.target_project_id') end diff --git a/app/models/description_version.rb b/app/models/description_version.rb index f69564f4893..96c8553c101 100644 --- a/app/models/description_version.rb +++ b/app/models/description_version.rb @@ -29,4 +29,4 @@ class DescriptionVersion < ApplicationRecord end end -DescriptionVersion.prepend_if_ee('EE::DescriptionVersion') +DescriptionVersion.prepend_mod_with('DescriptionVersion') diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb index 5cfd8f3ec8e..ca65cf38f0d 100644 --- a/app/models/design_management/version.rb +++ b/app/models/design_management/version.rb @@ -58,6 +58,7 @@ module DesignManagement scope :ordered, -> { order(id: :desc) } scope :for_issue, -> (issue) { where(issue: issue) } scope :by_sha, -> (sha) { where(sha: sha) } + scope :with_author, -> { includes(:author) } # This is the one true way to create a Version. # @@ -94,7 +95,7 @@ module DesignManagement version end - rescue + rescue StandardError raise CouldNotCreateVersion.new(sha, issue_id, design_actions) end diff --git a/app/models/discussion_note.rb b/app/models/discussion_note.rb index 5049107da2c..6621b30b645 100644 --- a/app/models/discussion_note.rb +++ b/app/models/discussion_note.rb @@ -5,7 +5,7 @@ # A note of this type can be resolvable. class DiscussionNote < Note # This prepend must stay here because the `validates` below depends on it. - prepend_if_ee('EE::DiscussionNote') # rubocop: disable Cop/InjectEnterpriseEditionModule + prepend_mod_with('DiscussionNote') # rubocop: disable Cop/InjectEnterpriseEditionModule # Names of all implementers of `Noteable` that support discussions. def self.noteable_types diff --git a/app/models/email.rb b/app/models/email.rb index c5154267ff0..0140f784842 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -22,7 +22,7 @@ class Email < ApplicationRecord self.reconfirmable = false # currently email can't be changed, no need to reconfirm - delegate :username, :can?, to: :user + delegate :username, :can?, :pending_invitations, :accept_pending_invitations!, to: :user def email=(value) write_attribute(:email, value.downcase.strip) @@ -32,10 +32,6 @@ class Email < ApplicationRecord self.errors.add(:email, 'has already been taken') if User.exists?(email: self.email) end - def accept_pending_invitations! - user.accept_pending_invitations! - end - def validate_email_format self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email) end diff --git a/app/models/environment.rb b/app/models/environment.rb index 4ee93b0ba4a..2e677a3d177 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -24,13 +24,13 @@ class Environment < ApplicationRecord has_many :self_managed_prometheus_alert_events, inverse_of: :environment has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment - has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment', inverse_of: :environment + has_one :last_deployment, -> { success.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment has_one :last_deployable, through: :last_deployment, source: 'deployable', source_type: 'CommitStatus' has_one :last_pipeline, through: :last_deployable, source: 'pipeline' has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment' has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus' has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline' - has_one :upcoming_deployment, -> { running.order('deployments.id DESC') }, class_name: 'Deployment', inverse_of: :environment + has_one :upcoming_deployment, -> { running.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment before_validation :nullify_external_url @@ -269,7 +269,7 @@ class Environment < ApplicationRecord Gitlab::OptimisticLocking.retry_lock(deployment.deployable, name: 'environment_cancel_deployment_jobs') do |deployable| deployable.cancel! if deployable&.cancelable? end - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, environment_id: id, deployment_id: deployment.id) end end @@ -406,7 +406,7 @@ class Environment < ApplicationRecord end def elastic_stack_available? - !!deployment_platform&.cluster&.application_elastic_stack_available? + !!deployment_platform&.cluster&.elastic_stack_available? end def rollout_status @@ -471,4 +471,4 @@ class Environment < ApplicationRecord end end -Environment.prepend_if_ee('EE::Environment') +Environment.prepend_mod_with('Environment') diff --git a/app/models/epic.rb b/app/models/epic.rb index 93f286f97d3..81cd342576f 100644 --- a/app/models/epic.rb +++ b/app/models/epic.rb @@ -18,4 +18,4 @@ class Epic < ApplicationRecord end end -Epic.prepend_if_ee('EE::Epic') +Epic.prepend_mod_with('Epic') diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 9a9fbc6a801..956b5d6470f 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -38,7 +38,7 @@ module ErrorTracking attr_encrypted :token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' after_save :clear_reactive_cache! diff --git a/app/models/event.rb b/app/models/event.rb index 401dfc4cb02..5b755736f47 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -448,4 +448,4 @@ class Event < ApplicationRecord end end -Event.prepend_if_ee('EE::Event') +Event.prepend_mod_with('Event') diff --git a/app/models/external_pull_request.rb b/app/models/external_pull_request.rb index 1487a6387f0..3fc166203e7 100644 --- a/app/models/external_pull_request.rb +++ b/app/models/external_pull_request.rb @@ -72,6 +72,10 @@ class ExternalPullRequest < ApplicationRecord end end + def modified_paths + project.repository.diff_stats(target_sha, source_sha).paths + end + private def actual_source_branch_sha diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 330815ab8c1..0cb3662368c 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -128,4 +128,4 @@ class GpgKey < ApplicationRecord end end -GpgKey.prepend_if_ee('EE::GpgKey') +GpgKey.prepend_mod_with('GpgKey') diff --git a/app/models/group.rb b/app/models/group.rb index 2967c1ffc1d..da795651c63 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -34,7 +34,7 @@ class Group < Namespace has_many :members_and_requesters, as: :source, class_name: 'GroupMember' has_many :milestones - has_many :services + has_many :integrations has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink' has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink' has_many :shared_groups, through: :shared_group_links, source: :shared_group @@ -67,6 +67,8 @@ class Group < Namespace has_one :import_state, class_name: 'GroupImportState', inverse_of: :group + has_many :bulk_import_exports, class_name: 'BulkImports::Export', inverse_of: :group + has_many :group_deploy_keys_groups, inverse_of: :group has_many :group_deploy_keys, through: :group_deploy_keys_groups has_many :group_deploy_tokens @@ -105,21 +107,21 @@ class Group < Namespace scope :with_users, -> { includes(:users) } + scope :with_onboarding_progress, -> { joins(:onboarding_progress) } + scope :by_id, ->(groups) { where(id: groups) } scope :for_authorized_group_members, -> (user_ids) do joins(:group_members) - .where("members.user_id IN (?)", user_ids) + .where(members: { user_id: user_ids }) .where("access_level >= ?", Gitlab::Access::GUEST) end scope :for_authorized_project_members, -> (user_ids) do joins(projects: :project_authorizations) - .where("project_authorizations.user_id IN (?)", user_ids) + .where(project_authorizations: { user_id: user_ids }) end - delegate :default_branch_name, to: :namespace_settings - class << self def sort_by_attribute(method) if method == 'storage_size_desc' @@ -155,7 +157,7 @@ class Group < Namespace def select_for_project_authorization if current_scope.joins_values.include?(:shared_projects) joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id') - .where('project_namespace.share_with_group_lock = ?', false) + .where(project_namespace: { share_with_group_lock: false }) .select("projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level") else super @@ -163,12 +165,12 @@ class Group < Namespace end def without_integration(integration) - services = Service + integrations = Integration .select('1') .where('services.group_id = namespaces.id') .where(type: integration.type) - where('NOT EXISTS (?)', services) + where('NOT EXISTS (?)', integrations) end # This method can be used only if all groups have the same top-level @@ -448,6 +450,20 @@ class Group < Namespace .where(source_id: id) end + def authorizable_members_with_parents + source_ids = + if has_parent? + self_and_ancestors.reorder(nil).select(:id) + else + id + end + + group_hierarchy_members = GroupMember.where(source_id: source_ids) + + GroupMember.from_union([group_hierarchy_members, + members_from_self_and_ancestor_group_shares]).authorizable + end + def members_with_parents # Avoids an unnecessary SELECT when the group has no parents source_ids = @@ -553,11 +569,22 @@ class Group < Namespace def max_member_access_for_user(user, only_concrete_membership: false) return GroupMember::NO_ACCESS unless user return GroupMember::OWNER if user.can_admin_all_resources? && !only_concrete_membership + # Use the preloaded value that exists instead of performing the db query again(cached or not). + # Groups::GroupMembersController#preload_max_access makes use of this by + # calling Group#max_member_access. This helps when we have a process + # that may query this multiple times from the outside through a policy query + # like the GroupPolicy#lookup_access_level! does as a condition for any role + return user.max_access_for_group[id] if user.max_access_for_group[id] + + max_member_access(user) + end - max_member_access = members_with_parents.where(user_id: user) - .reorder(access_level: :desc) - .first - &.access_level + def max_member_access(user) + max_member_access = members_with_parents + .where(user_id: user) + .reorder(access_level: :desc) + .first + &.access_level max_member_access || GroupMember::NO_ACCESS end @@ -622,7 +649,7 @@ class Group < Namespace end def access_request_approvers_to_be_notified - members.owners.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) + members.owners.connected_to_user.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) end def supports_events? @@ -693,6 +720,14 @@ class Group < Namespace Gitlab::ServiceDesk.supported? && all_projects.service_desk_enabled.exists? end + def to_ability_name + model_name.singular + end + + def activity_path + Gitlab::Routing.url_helpers.activity_group_path(self) + end + private def update_two_factor_requirement @@ -820,7 +855,12 @@ class Group < Namespace end def uncached_ci_variables_for(ref, project, environment: nil) - list_of_ids = [self] + ancestors + list_of_ids = if root_ancestor.use_traversal_ids? + [self] + ancestors(hierarchy_order: :asc) + else + [self] + ancestors + end + variables = Ci::GroupVariable.where(group: list_of_ids) variables = variables.unprotected unless project.protected_for?(ref) @@ -835,4 +875,4 @@ class Group < Namespace end end -Group.prepend_if_ee('EE::Group') +Group.prepend_mod_with('Group') diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index b625a70b444..a28b97e63e5 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -4,6 +4,7 @@ class ProjectHook < WebHook include TriggerableHooks include Presentable include Limitable + extend ::Gitlab::Utils::Override self.limit_scope = :project @@ -29,6 +30,15 @@ class ProjectHook < WebHook def pluralized_name _('Webhooks') end + + def web_hooks_disable_failed? + Feature.enabled?(:web_hooks_disable_failed, project) + end + + override :rate_limit + def rate_limit + project.actual_limits.limit_for(:web_hook_calls) + end end -ProjectHook.prepend_if_ee('EE::ProjectHook') +ProjectHook.prepend_mod_with('ProjectHook') diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb index 4caa45a13d4..1a466b333a5 100644 --- a/app/models/hooks/service_hook.rb +++ b/app/models/hooks/service_hook.rb @@ -3,12 +3,10 @@ class ServiceHook < WebHook include Presentable - belongs_to :service - validates :service, presence: true + belongs_to :integration, foreign_key: :service_id + validates :integration, presence: true - # rubocop: disable CodeReuse/ServiceClass def execute(data, hook_name = 'service_hook') - WebHookService.new(self, data, hook_name).execute + super(data, hook_name) end - # rubocop: enable CodeReuse/ServiceClass end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index dbd5a1b032a..02b4feb4ccc 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -3,6 +3,11 @@ class WebHook < ApplicationRecord include Sortable + FAILURE_THRESHOLD = 3 # three strikes + INITIAL_BACKOFF = 10.minutes + MAX_BACKOFF = 1.day + BACKOFF_GROWTH_FACTOR = 2.0 + attr_encrypted :token, mode: :per_attribute_iv, algorithm: 'aes-256-gcm', @@ -21,15 +26,27 @@ class WebHook < ApplicationRecord validates :token, format: { without: /\n/ } validates :push_events_branch_filter, branch_filter: true + scope :executable, -> do + next all unless Feature.enabled?(:web_hooks_disable_failed) + + where('recent_failures <= ? AND (disabled_until IS NULL OR disabled_until < ?)', FAILURE_THRESHOLD, Time.current) + end + + def executable? + return true unless web_hooks_disable_failed? + + recent_failures <= FAILURE_THRESHOLD && (disabled_until.nil? || disabled_until < Time.current) + end + # rubocop: disable CodeReuse/ServiceClass def execute(data, hook_name) - WebHookService.new(self, data, hook_name).execute + WebHookService.new(self, data, hook_name).execute if executable? end # rubocop: enable CodeReuse/ServiceClass # rubocop: disable CodeReuse/ServiceClass def async_execute(data, hook_name) - WebHookService.new(self, data, hook_name).async_execute + WebHookService.new(self, data, hook_name).async_execute if executable? end # rubocop: enable CodeReuse/ServiceClass @@ -41,4 +58,31 @@ class WebHook < ApplicationRecord def help_path 'user/project/integrations/webhooks' end + + def next_backoff + return MAX_BACKOFF if backoff_count >= 8 # optimization to prevent expensive exponentiation and possible overflows + + (INITIAL_BACKOFF * (BACKOFF_GROWTH_FACTOR**backoff_count)) + .clamp(INITIAL_BACKOFF, MAX_BACKOFF) + .seconds + end + + def disable! + update!(recent_failures: FAILURE_THRESHOLD + 1) + end + + def enable! + update!(recent_failures: 0, disabled_until: nil, backoff_count: 0) + end + + # Overridden in ProjectHook and GroupHook, other webhooks are not rate-limited. + def rate_limit + nil + end + + private + + def web_hooks_disable_failed? + Feature.enabled?(:web_hooks_disable_failed) + end end diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index e2230a2d644..0c96d5d4b6d 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -5,9 +5,12 @@ class WebHookLog < ApplicationRecord include Presentable include DeleteWithLimit include CreatedAtFilterable + include PartitionedTable self.primary_key = :id + partitioned_by :created_at, strategy: :monthly + belongs_to :web_hook serialize :request_headers, Hash # rubocop:disable Cop/ActiveRecordSerialize diff --git a/app/models/hooks/web_hook_log_archived.rb b/app/models/hooks/web_hook_log_archived.rb new file mode 100644 index 00000000000..a1c8a44f5ba --- /dev/null +++ b/app/models/hooks/web_hook_log_archived.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# This model is not intended to be used. +# It is a temporary reference to the old non-partitioned +# web_hook_logs table. +# Please refer to https://gitlab.com/groups/gitlab-org/-/epics/5558 +# for details. +# rubocop:disable Gitlab/NamespacedClass: This is a temporary class with no relevant namespace +# WebHook, WebHookLog and all hooks are defined outside of a namespace +class WebHookLogArchived < ApplicationRecord + self.table_name = 'web_hook_logs_archived' +end diff --git a/app/models/hooks/web_hook_log_partitioned.rb b/app/models/hooks/web_hook_log_partitioned.rb deleted file mode 100644 index b4b150afb6a..00000000000 --- a/app/models/hooks/web_hook_log_partitioned.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -# This model is not yet intended to be used. -# It is in a transitioning phase while we are partitioning -# the web_hook_logs table on the database-side. -# Please refer to https://gitlab.com/groups/gitlab-org/-/epics/5558 -# for details. -# rubocop:disable Gitlab/NamespacedClass: This is a temporary class with no relevant namespace -# WebHook, WebHookLog and all hooks are defined outside of a namespace -class WebHookLogPartitioned < ApplicationRecord - include PartitionedTable - - self.table_name = 'web_hook_logs_part_0c5294f417' - self.primary_key = :id - - partitioned_by :created_at, strategy: :monthly -end diff --git a/app/models/identity.rb b/app/models/identity.rb index fc97c68b756..df1185f330d 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -51,4 +51,4 @@ class Identity < ApplicationRecord end end -Identity.prepend_if_ee('EE::Identity') +Identity.prepend_mod_with('Identity') diff --git a/app/models/identity/uniqueness_scopes.rb b/app/models/identity/uniqueness_scopes.rb index c1890865a1c..b41b4572e82 100644 --- a/app/models/identity/uniqueness_scopes.rb +++ b/app/models/identity/uniqueness_scopes.rb @@ -10,4 +10,4 @@ class Identity < ApplicationRecord end end -Identity::UniquenessScopes.prepend_if_ee('EE::Identity::UniquenessScopes') +Identity::UniquenessScopes.prepend_mod_with('Identity::UniquenessScopes') diff --git a/app/models/incident_management/project_incident_management_setting.rb b/app/models/incident_management/project_incident_management_setting.rb index 4887265be88..b6da93508c2 100644 --- a/app/models/incident_management/project_incident_management_setting.rb +++ b/app/models/incident_management/project_incident_management_setting.rb @@ -12,7 +12,7 @@ module IncidentManagement attr_encrypted :pagerduty_token, mode: :per_attribute_iv, - key: ::Settings.attr_encrypted_db_key_base_truncated, + key: ::Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: false, # No need to encode for binary column https://github.com/attr-encrypted/attr_encrypted#the-encode-encode_iv-encode_salt-and-default_encoding-options encode_iv: false @@ -52,4 +52,4 @@ module IncidentManagement end end -IncidentManagement::ProjectIncidentManagementSetting.prepend_if_ee('EE::IncidentManagement::ProjectIncidentManagementSetting') +IncidentManagement::ProjectIncidentManagementSetting.prepend_mod_with('IncidentManagement::ProjectIncidentManagementSetting') diff --git a/app/models/instance_metadata.rb b/app/models/instance_metadata.rb index 96622d0b1b3..6cac78178e0 100644 --- a/app/models/instance_metadata.rb +++ b/app/models/instance_metadata.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true class InstanceMetadata - attr_reader :version, :revision + attr_reader :version, :revision, :kas def initialize(version: Gitlab::VERSION, revision: Gitlab.revision) @version = version @revision = revision + @kas = ::InstanceMetadata::Kas.new end end diff --git a/app/models/instance_metadata/kas.rb b/app/models/instance_metadata/kas.rb new file mode 100644 index 00000000000..7d2d71120b5 --- /dev/null +++ b/app/models/instance_metadata/kas.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class InstanceMetadata::Kas + attr_reader :enabled, :version, :external_url + + def initialize + @enabled = Gitlab::Kas.enabled? + @version = Gitlab::Kas.version if @enabled + @external_url = Gitlab::Kas.external_url if @enabled + end + + def self.declarative_policy_class + "InstanceMetadataPolicy" + end +end diff --git a/app/models/service.rb b/app/models/integration.rb index aadc75ae710..13203cd4e95 100644 --- a/app/models/service.rb +++ b/app/models/integration.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -# To add new service you should build a class inherited from Service +# To add new integration you should build a class inherited from Integration # and implement a set of methods -class Service < ApplicationRecord +class Integration < ApplicationRecord include Sortable include Importable include ProjectServicesLoggable @@ -10,24 +10,29 @@ class Service < ApplicationRecord include FromUnion include EachBatch - SERVICE_NAMES = %w[ + # TODO Rename the table: https://gitlab.com/gitlab-org/gitlab/-/issues/201856 + self.table_name = 'services' + + INTEGRATION_NAMES = %w[ asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack ].freeze - PROJECT_SPECIFIC_SERVICE_NAMES = %w[ + PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[ datadog jenkins ].freeze - # Fake services to help with local development. - DEV_SERVICE_NAMES = %w[ + # Fake integrations to help with local development. + DEV_INTEGRATION_NAMES = %w[ mock_ci mock_monitoring ].freeze serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize + attribute :type, Gitlab::Integrations::StiType.new + default_value_for :active, false default_value_for :alert_events, true default_value_for :category, 'common' @@ -47,18 +52,18 @@ class Service < ApplicationRecord after_commit :reset_updated_properties - belongs_to :project, inverse_of: :services - belongs_to :group, inverse_of: :services - has_one :service_hook + belongs_to :project, inverse_of: :integrations + belongs_to :group, inverse_of: :integrations + has_one :service_hook, inverse_of: :integration, foreign_key: :service_id - validates :project_id, presence: true, unless: -> { template? || instance? || group_id } - validates :group_id, presence: true, unless: -> { template? || instance? || project_id } - validates :project_id, :group_id, absence: true, if: -> { template? || instance? } + validates :project_id, presence: true, unless: -> { template? || instance_level? || group_level? } + validates :group_id, presence: true, unless: -> { template? || instance_level? || project_level? } + validates :project_id, :group_id, absence: true, if: -> { template? || instance_level? } validates :type, presence: true validates :type, uniqueness: { scope: :template }, if: :template? - validates :type, uniqueness: { scope: :instance }, if: :instance? - validates :type, uniqueness: { scope: :project_id }, if: :project_id? - validates :type, uniqueness: { scope: :group_id }, if: :group_id? + validates :type, uniqueness: { scope: :instance }, if: :instance_level? + validates :type, uniqueness: { scope: :project_id }, if: :project_level? + validates :type, uniqueness: { scope: :group_id }, if: :group_level? validate :validate_is_instance_or_template validate :validate_belongs_to_project_or_group @@ -164,22 +169,23 @@ class Service < ApplicationRecord end def self.create_nonexistent_templates - nonexistent_services = list_nonexistent_services_for(for_template) + nonexistent_services = build_nonexistent_services_for(for_template) return if nonexistent_services.empty? # Create within a transaction to perform the lowest possible SQL queries. transaction do - nonexistent_services.each do |service_type| - service_type.constantize.create(template: true) + nonexistent_services.each do |service| + service.template = true + service.save end end end private_class_method :create_nonexistent_templates def self.find_or_initialize_non_project_specific_integration(name, instance: false, group_id: nil) - if name.in?(available_services_names(include_project_specific: false)) - "#{name}_service".camelize.constantize.find_or_initialize_by(instance: instance, group_id: group_id) - end + return unless name.in?(available_services_names(include_project_specific: false)) + + service_name_to_model(name).find_or_initialize_by(instance: instance, group_id: group_id) end def self.find_or_initialize_all_non_project_specific(scope) @@ -187,19 +193,23 @@ class Service < ApplicationRecord end def self.build_nonexistent_services_for(scope) - list_nonexistent_services_for(scope).map do |service_type| - service_type.constantize.new + nonexistent_services_types_for(scope).map do |service_type| + service_type_to_model(service_type).new end end private_class_method :build_nonexistent_services_for - def self.list_nonexistent_services_for(scope) + # Returns a list of service types that do not exist in the given scope. + # Example: ["AsanaService", ...] + def self.nonexistent_services_types_for(scope) # Using #map instead of #pluck to save one query count. This is because # ActiveRecord loaded the object here, so we don't need to query again later. available_services_types(include_project_specific: false) - scope.map(&:type) end - private_class_method :list_nonexistent_services_for + private_class_method :nonexistent_services_types_for + # Returns a list of available service names. + # Example: ["asana", ...] def self.available_services_names(include_project_specific: true, include_dev: true) service_names = services_names service_names += project_specific_services_names if include_project_specific @@ -209,40 +219,61 @@ class Service < ApplicationRecord end def self.services_names - SERVICE_NAMES + INTEGRATION_NAMES end def self.dev_services_names return [] unless Rails.env.development? - DEV_SERVICE_NAMES + DEV_INTEGRATION_NAMES end def self.project_specific_services_names - PROJECT_SPECIFIC_SERVICE_NAMES + PROJECT_SPECIFIC_INTEGRATION_NAMES end + # Returns a list of available service types. + # Example: ["AsanaService", ...] def self.available_services_types(include_project_specific: true, include_dev: true) available_services_names(include_project_specific: include_project_specific, include_dev: include_dev).map do |service_name| - "#{service_name}_service".camelize + service_name_to_type(service_name) end end + # Returns the model for the given service name. + # Example: "asana" => Integrations::Asana + def self.service_name_to_model(name) + type = service_name_to_type(name) + service_type_to_model(type) + end + + # Returns the STI type for the given service name. + # Example: "asana" => "AsanaService" + def self.service_name_to_type(name) + "#{name}_service".camelize + end + + # Returns the model for the given STI type. + # Example: "AsanaService" => Integrations::Asana + def self.service_type_to_model(type) + Gitlab::Integrations::StiType.new.cast(type).constantize + end + private_class_method :service_type_to_model + def self.build_from_integration(integration, project_id: nil, group_id: nil) - service = integration.dup + new_integration = integration.dup if integration.supports_data_fields? data_fields = integration.data_fields.dup - data_fields.service = service + data_fields.integration = new_integration end - service.template = false - service.instance = false - service.project_id = project_id - service.group_id = group_id - service.inherit_from_id = integration.id if integration.instance? || integration.group - service.active = false if service.invalid? - service + new_integration.template = false + new_integration.instance = false + new_integration.project_id = project_id + new_integration.group_id = group_id + new_integration.inherit_from_id = integration.id if integration.instance_level? || integration.group_level? + new_integration end def self.instance_exists_for?(type) @@ -269,7 +300,7 @@ class Service < ApplicationRecord private_class_method :instance_level_integration def self.create_from_active_default_integrations(scope, association, with_templates: false) - group_ids = scope.ancestors.select(:id) + group_ids = sorted_ancestors(scope).select(:id) array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]' from_union([ @@ -340,7 +371,7 @@ class Service < ApplicationRecord # Expose a list of fields in the JSON endpoint. # - # This list is used in `Service#as_json(only: json_fields)`. + # This list is used in `Integration#as_json(only: json_fields)`. def json_fields %w[active] end @@ -407,16 +438,24 @@ class Service < ApplicationRecord { success: result.present?, result: result } end - # Disable test for instance-level and group-level services. + # Disable test for instance-level and group-level integrations. # https://gitlab.com/gitlab-org/gitlab/-/issues/213138 def can_test? - !instance? && !group_id + !(instance_level? || group_level?) end def project_level? project_id.present? end + def group_level? + group_id.present? + end + + def instance_level? + instance? + end + def parent project || group end @@ -424,7 +463,7 @@ class Service < ApplicationRecord # Returns a hash of the properties that have been assigned a new value since last save, # indicating their original values (attr => original value). # ActiveRecord does not provide a mechanism to track changes in serialized keys, - # so we need a specific implementation for service properties. + # so we need a specific implementation for integration properties. # This allows to track changes to properties set with the accessor methods, # but not direct manipulation of properties hash. def updated_properties @@ -452,12 +491,21 @@ class Service < ApplicationRecord private + # Ancestors sorted by hierarchy depth in bottom-top order. + def self.sorted_ancestors(scope) + if scope.root_ancestor.use_traversal_ids? + Namespace.from(scope.ancestors(hierarchy_order: :asc)) + else + scope.ancestors + end + end + def validate_is_instance_or_template - errors.add(:template, 'The service should be a service template or instance-level integration') if template? && instance? + errors.add(:template, 'The service should be a service template or instance-level integration') if template? && instance_level? end def validate_belongs_to_project_or_group - errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_id && group_id + errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_level? && group_level? end def validate_recipients? @@ -465,4 +513,4 @@ class Service < ApplicationRecord end end -Service.prepend_if_ee('EE::Service') +Integration.prepend_mod_with('Integration') diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb new file mode 100644 index 00000000000..7949563a1dc --- /dev/null +++ b/app/models/integrations/asana.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'asana' + +module Integrations + class Asana < Integration + include ActionView::Helpers::UrlHelper + + prop_accessor :api_key, :restrict_to_branch + validates :api_key, presence: true, if: :activated? + + def title + 'Asana' + end + + def description + s_('AsanaService|Add commit messages as comments to Asana tasks.') + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer' + s_('Add commit messages as comments to Asana tasks. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'asana' + end + + def fields + [ + { + type: 'text', + name: 'api_key', + title: 'API key', + help: s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.'), + # Example Personal Access Token from Asana docs + placeholder: '0/68a9e79b868c6789e79a124c30b0', + required: true + }, + { + type: 'text', + name: 'restrict_to_branch', + title: 'Restrict to branch (optional)', + help: s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') + } + ] + end + + def self.supported_events + %w(push) + end + + def client + @_client ||= begin + ::Asana::Client.new do |c| + c.authentication :access_token, api_key + end + end + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + # check the branch restriction is poplulated and branch is not included + branch = Gitlab::Git.ref_name(data[:ref]) + branch_restriction = restrict_to_branch.to_s + if branch_restriction.present? && branch_restriction.index(branch).nil? + return + end + + user = data[:user_name] + project_name = project.full_name + + data[:commits].each do |commit| + push_msg = s_("AsanaService|%{user} pushed to branch %{branch} of %{project_name} ( %{commit_url} ):") % { user: user, branch: branch, project_name: project_name, commit_url: commit[:url] } + check_commit(commit[:message], push_msg) + end + end + + def check_commit(message, push_msg) + # matches either: + # - #1234 + # - https://app.asana.com/0/{project_gid}/{task_gid} + # optionally preceded with: + # - fix/ed/es/ing + # - close/s/d + # - closing + issue_finder = %r{(fix\w*|clos[ei]\w*+)?\W*(?:https://app\.asana\.com/\d+/\w+/(\w+)|#(\w+))}i + + message.scan(issue_finder).each do |tuple| + # tuple will be + # [ 'fix', 'id_from_url', 'id_from_pound' ] + taskid = tuple[2] || tuple[1] + + begin + task = ::Asana::Resources::Task.find_by_id(client, taskid) + task.add_comment(text: "#{push_msg} #{message}") + + if tuple[0] + task.update(completed: true) + end + rescue StandardError => e + log_error(e.message) + next + end + end + end + end +end diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb new file mode 100644 index 00000000000..6a36045330a --- /dev/null +++ b/app/models/integrations/assembla.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Integrations + class Assembla < Integration + prop_accessor :token, :subdomain + validates :token, presence: true, if: :activated? + + def title + 'Assembla' + end + + def description + _('Manage projects.') + end + + def self.to_param + 'assembla' + end + + def fields + [ + { type: 'text', name: 'token', placeholder: '', required: true }, + { type: 'text', name: 'subdomain', placeholder: '' } + ] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + url = "https://atlas.assembla.com/spaces/#{subdomain}/github_tool?secret_key=#{token}" + Gitlab::HTTP.post(url, body: { payload: data }.to_json, headers: { 'Content-Type' => 'application/json' }) + end + end +end diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb new file mode 100644 index 00000000000..82111c7322e --- /dev/null +++ b/app/models/integrations/bamboo.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +module Integrations + class Bamboo < CiService + include ActionView::Helpers::UrlHelper + include ReactiveService + + prop_accessor :bamboo_url, :build_key, :username, :password + + validates :bamboo_url, presence: true, public_url: true, if: :activated? + validates :build_key, presence: true, if: :activated? + validates :username, + presence: true, + if: ->(service) { service.activated? && service.password } + validates :password, + presence: true, + if: ->(service) { service.activated? && service.username } + + attr_accessor :response + + after_save :compose_service_hook, if: :activated? + before_update :reset_password + + def compose_service_hook + hook = service_hook || build_service_hook + hook.save + end + + def reset_password + if bamboo_url_changed? && !password_touched? + self.password = nil + end + end + + def title + s_('BambooService|Atlassian Bamboo') + end + + def description + s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo.') + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), target: '_blank', rel: 'noopener noreferrer' + s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo. You must set up automatic revision labeling and a repository trigger in Bamboo. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'bamboo' + end + + def fields + [ + { + type: 'text', + name: 'bamboo_url', + title: s_('BambooService|Bamboo URL'), + placeholder: s_('https://bamboo.example.com'), + help: s_('BambooService|Bamboo service root URL.'), + required: true + }, + { + type: 'text', + name: 'build_key', + placeholder: s_('KEY'), + help: s_('BambooService|Bamboo build plan key.'), + required: true + }, + { + type: 'text', + name: 'username', + help: s_('BambooService|The user with API access to the Bamboo server.') + }, + { + type: 'password', + name: 'password', + non_empty_password_title: s_('ProjectService|Enter new password'), + non_empty_password_help: s_('ProjectService|Leave blank to use your current password') + } + ] + end + + def build_page(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:build_page] } + end + + def commit_status(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + get_path("updateAndBuild.action", { buildKey: build_key }) + end + + def calculate_reactive_cache(sha, ref) + response = try_get_path("rest/api/latest/result/byChangeset/#{sha}") + + { build_page: read_build_page(response), commit_status: read_commit_status(response) } + end + + private + + def get_build_result(response) + return if response&.code != 200 + + # May be nil if no result, a single result hash, or an array if multiple results for a given changeset. + result = response.dig('results', 'results', 'result') + + # In case of multiple results, arbitrarily assume the last one is the most relevant. + return result.last if result.is_a?(Array) + + result + end + + def read_build_page(response) + result = get_build_result(response) + key = + if result.blank? + # If actual build link can't be determined, send user to build summary page. + build_key + else + # If actual build link is available, go to build result page. + result.dig('planResultKey', 'key') + end + + build_url("browse/#{key}") + end + + def read_commit_status(response) + return :error unless response && (response.code == 200 || response.code == 404) + + result = get_build_result(response) + status = + if result.blank? + 'Pending' + else + result.dig('buildState') + end + + return :error unless status.present? + + if status.include?('Success') + 'success' + elsif status.include?('Failed') + 'failed' + elsif status.include?('Pending') + 'pending' + else + :error + end + end + + def try_get_path(path, query_params = {}) + params = build_get_params(query_params) + params[:extra_log_info] = { project_id: project_id } + + Gitlab::HTTP.try_get(build_url(path), params) + end + + def get_path(path, query_params = {}) + Gitlab::HTTP.get(build_url(path), build_get_params(query_params)) + end + + def build_url(path) + Gitlab::Utils.append_path(bamboo_url, path) + end + + def build_get_params(query_params) + params = { verify: false, query: query_params } + return params if username.blank? && password.blank? + + query_params[:os_authType] = 'basic' + params[:basic_auth] = basic_auth + params + end + + def basic_auth + { username: username, password: password } + end + end +end diff --git a/app/models/integrations/builds_email.rb b/app/models/integrations/builds_email.rb new file mode 100644 index 00000000000..2628848667e --- /dev/null +++ b/app/models/integrations/builds_email.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# This class is to be removed with 9.1 +# We should also by then remove BuildsEmailService from database +# https://gitlab.com/gitlab-org/gitlab/-/issues/331064 +module Integrations + class BuildsEmail < Integration + def self.to_param + 'builds_email' + end + + def self.supported_events + %w[] + end + end +end diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb new file mode 100644 index 00000000000..eede3d00307 --- /dev/null +++ b/app/models/integrations/campfire.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Integrations + class Campfire < Integration + prop_accessor :token, :subdomain, :room + validates :token, presence: true, if: :activated? + + def title + 'Campfire' + end + + def description + 'Send notifications about push events to Campfire chat rooms.' + end + + def self.to_param + 'campfire' + end + + def fields + [ + { type: 'text', name: 'token', placeholder: '', required: true }, + { type: 'text', name: 'subdomain', placeholder: '' }, + { type: 'text', name: 'room', placeholder: '' } + ] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + message = build_message(data) + speak(self.room, message, auth) + end + + private + + def base_uri + @base_uri ||= "https://#{subdomain}.campfirenow.com" + end + + def auth + # use a dummy password, as explained in the Campfire API doc: + # https://github.com/basecamp/campfire-api#authentication + @auth ||= { + basic_auth: { + username: token, + password: 'X' + } + } + end + + # Post a message into a room, returns the message Hash in case of success. + # Returns nil otherwise. + # https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message + def speak(room_name, message, auth) + room = rooms(auth).find { |r| r["name"] == room_name } + return unless room + + path = "/room/#{room["id"]}/speak.json" + body = { + body: { + message: { + type: 'TextMessage', + body: message + } + } + } + res = Gitlab::HTTP.post(path, base_uri: base_uri, **auth.merge(body)) + res.code == 201 ? res : nil + end + + # Returns a list of rooms, or []. + # https://github.com/basecamp/campfire-api/blob/master/sections/rooms.md#get-rooms + def rooms(auth) + res = Gitlab::HTTP.get("/rooms.json", base_uri: base_uri, **auth) + res.code == 200 ? res["rooms"] : [] + end + + def build_message(push) + ref = Gitlab::Git.ref_name(push[:ref]) + before = push[:before] + after = push[:after] + + message = [] + message << "[#{project.full_name}] " + message << "#{push[:user_name]} " + + if Gitlab::Git.blank_ref?(before) + message << "pushed new branch #{ref} \n" + elsif Gitlab::Git.blank_ref?(after) + message << "removed branch #{ref} \n" + else + message << "pushed #{push[:total_commits_count]} commits to #{ref}. " + message << "#{project.web_url}/compare/#{before}...#{after}" + end + + message.join + end + end +end diff --git a/app/models/integrations/chat_message/alert_message.rb b/app/models/integrations/chat_message/alert_message.rb new file mode 100644 index 00000000000..ef0579124fe --- /dev/null +++ b/app/models/integrations/chat_message/alert_message.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class AlertMessage < BaseMessage + attr_reader :title + attr_reader :alert_url + attr_reader :severity + attr_reader :events + attr_reader :status + attr_reader :started_at + + def initialize(params) + @project_name = params[:project_name] || params.dig(:project, :path_with_namespace) + @project_url = params.dig(:project, :web_url) || params[:project_url] + @title = params.dig(:object_attributes, :title) + @alert_url = params.dig(:object_attributes, :url) + @severity = params.dig(:object_attributes, :severity) + @events = params.dig(:object_attributes, :events) + @status = params.dig(:object_attributes, :status) + @started_at = params.dig(:object_attributes, :started_at) + end + + def attachments + [{ + title: title, + title_link: alert_url, + color: attachment_color, + fields: attachment_fields + }] + end + + def message + "Alert firing in #{project_name}" + end + + private + + def attachment_color + "#C95823" + end + + def attachment_fields + [ + { + title: "Severity", + value: severity.to_s.humanize, + short: true + }, + { + title: "Events", + value: events, + short: true + }, + { + title: "Status", + value: status.to_s.humanize, + short: true + }, + { + title: "Start time", + value: format_time(started_at), + short: true + } + ] + end + + # This formats time into the following format + # April 23rd, 2020 1:06AM UTC + def format_time(time) + time = Time.zone.parse(time.to_s) + time.strftime("%B #{time.day.ordinalize}, %Y %l:%M%p %Z") + end + end + end +end diff --git a/app/models/integrations/chat_message/base_message.rb b/app/models/integrations/chat_message/base_message.rb new file mode 100644 index 00000000000..2f70384d3b9 --- /dev/null +++ b/app/models/integrations/chat_message/base_message.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class BaseMessage + RELATIVE_LINK_REGEX = %r{!\[[^\]]*\]\((/uploads/[^\)]*)\)}.freeze + + attr_reader :markdown + attr_reader :user_full_name + attr_reader :user_name + attr_reader :user_avatar + attr_reader :project_name + attr_reader :project_url + + def initialize(params) + @markdown = params[:markdown] || false + @project_name = params[:project_name] || params.dig(:project, :path_with_namespace) + @project_url = params.dig(:project, :web_url) || params[:project_url] + @user_full_name = params.dig(:user, :name) || params[:user_full_name] + @user_name = params.dig(:user, :username) || params[:user_name] + @user_avatar = params.dig(:user, :avatar_url) || params[:user_avatar] + end + + def user_combined_name + if user_full_name.present? + "#{user_full_name} (#{user_name})" + else + user_name + end + end + + def summary + return message if markdown + + format(message) + end + + def pretext + summary + end + + def fallback + format(message) + end + + def attachments + raise NotImplementedError + end + + def activity + raise NotImplementedError + end + + private + + def message + raise NotImplementedError + end + + def format(string) + Slack::Messenger::Util::LinkFormatter.format(format_relative_links(string)) + end + + def format_relative_links(string) + string.gsub(RELATIVE_LINK_REGEX, "#{project_url}\\1") + end + + def attachment_color + '#345' + end + + def link(text, url) + "[#{text}](#{url})" + end + + def pretty_duration(seconds) + parse_string = + if duration < 1.hour + '%M:%S' + else + '%H:%M:%S' + end + + Time.at(seconds).utc.strftime(parse_string) + end + end + end +end diff --git a/app/models/integrations/chat_message/deployment_message.rb b/app/models/integrations/chat_message/deployment_message.rb new file mode 100644 index 00000000000..c4f3bf9610d --- /dev/null +++ b/app/models/integrations/chat_message/deployment_message.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class DeploymentMessage < BaseMessage + attr_reader :commit_title + attr_reader :commit_url + attr_reader :deployable_id + attr_reader :deployable_url + attr_reader :environment + attr_reader :short_sha + attr_reader :status + attr_reader :user_url + + def initialize(data) + super + + @commit_title = data[:commit_title] + @commit_url = data[:commit_url] + @deployable_id = data[:deployable_id] + @deployable_url = data[:deployable_url] + @environment = data[:environment] + @short_sha = data[:short_sha] + @status = data[:status] + @user_url = data[:user_url] + end + + def attachments + [{ + text: "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{commit_title}", + color: color + }] + end + + def activity + {} + end + + private + + def message + if running? + "Starting deploy to #{environment}" + else + "Deploy to #{environment} #{humanized_status}" + end + end + + def color + case status + when 'success' + 'good' + when 'canceled' + 'warning' + when 'failed' + 'danger' + else + '#334455' + end + end + + def project_link + link(project_name, project_url) + end + + def deployment_link + link("##{deployable_id}", deployable_url) + end + + def user_link + link(user_combined_name, user_url) + end + + def commit_link + link(short_sha, commit_url) + end + + def humanized_status + status == 'success' ? 'succeeded' : status + end + + def running? + status == 'running' + end + end + end +end diff --git a/app/models/integrations/chat_message/issue_message.rb b/app/models/integrations/chat_message/issue_message.rb new file mode 100644 index 00000000000..5fa6bd4090f --- /dev/null +++ b/app/models/integrations/chat_message/issue_message.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class IssueMessage < BaseMessage + attr_reader :title + attr_reader :issue_iid + attr_reader :issue_url + attr_reader :action + attr_reader :state + attr_reader :description + + def initialize(params) + super + + obj_attr = params[:object_attributes] + obj_attr = HashWithIndifferentAccess.new(obj_attr) + @title = obj_attr[:title] + @issue_iid = obj_attr[:iid] + @issue_url = obj_attr[:url] + @action = obj_attr[:action] + @state = obj_attr[:state] + @description = obj_attr[:description] || '' + end + + def attachments + return [] unless opened_issue? + return description if markdown + + description_message + end + + def activity + { + title: "Issue #{state} by #{user_combined_name}", + subtitle: "in #{project_link}", + text: issue_link, + image: user_avatar + } + end + + private + + def message + "[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}" + end + + def opened_issue? + action == 'open' + end + + def description_message + [{ + title: issue_title, + title_link: issue_url, + text: format(description), + color: '#C95823' + }] + end + + def project_link + link(project_name, project_url) + end + + def issue_link + link(issue_title, issue_url) + end + + def issue_title + "#{Issue.reference_prefix}#{issue_iid} #{title}" + end + end + end +end diff --git a/app/models/integrations/chat_message/merge_message.rb b/app/models/integrations/chat_message/merge_message.rb new file mode 100644 index 00000000000..d2f48699f50 --- /dev/null +++ b/app/models/integrations/chat_message/merge_message.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class MergeMessage < BaseMessage + attr_reader :merge_request_iid + attr_reader :source_branch + attr_reader :target_branch + attr_reader :action + attr_reader :state + attr_reader :title + + def initialize(params) + super + + obj_attr = params[:object_attributes] + obj_attr = HashWithIndifferentAccess.new(obj_attr) + @merge_request_iid = obj_attr[:iid] + @source_branch = obj_attr[:source_branch] + @target_branch = obj_attr[:target_branch] + @action = obj_attr[:action] + @state = obj_attr[:state] + @title = format_title(obj_attr[:title]) + end + + def attachments + [] + end + + def activity + { + title: "Merge request #{state_or_action_text} by #{user_combined_name}", + subtitle: "in #{project_link}", + text: merge_request_link, + image: user_avatar + } + end + + private + + def format_title(title) + '*' + title.lines.first.chomp + '*' + end + + def message + merge_request_message + end + + def project_link + link(project_name, project_url) + end + + def merge_request_message + "#{user_combined_name} #{state_or_action_text} merge request #{merge_request_link} in #{project_link}" + end + + def merge_request_link + link(merge_request_title, merge_request_url) + end + + def merge_request_title + "#{MergeRequest.reference_prefix}#{merge_request_iid} #{title}" + end + + def merge_request_url + "#{project_url}/-/merge_requests/#{merge_request_iid}" + end + + def state_or_action_text + case action + when 'approved', 'unapproved' + action + when 'approval' + 'added their approval to' + when 'unapproval' + 'removed their approval from' + else + state + end + end + end + end +end diff --git a/app/models/integrations/chat_message/note_message.rb b/app/models/integrations/chat_message/note_message.rb new file mode 100644 index 00000000000..96675d2b27c --- /dev/null +++ b/app/models/integrations/chat_message/note_message.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class NoteMessage < BaseMessage + attr_reader :note + attr_reader :note_url + attr_reader :title + attr_reader :target + + def initialize(params) + super + + params = HashWithIndifferentAccess.new(params) + obj_attr = params[:object_attributes] + @note = obj_attr[:note] + @note_url = obj_attr[:url] + @target, @title = case obj_attr[:noteable_type] + when "Commit" + create_commit_note(params[:commit]) + when "Issue" + create_issue_note(params[:issue]) + when "MergeRequest" + create_merge_note(params[:merge_request]) + when "Snippet" + create_snippet_note(params[:snippet]) + end + end + + def attachments + return note if markdown + + description_message + end + + def activity + { + title: "#{user_combined_name} #{link('commented on ' + target, note_url)}", + subtitle: "in #{project_link}", + text: formatted_title, + image: user_avatar + } + end + + private + + def message + "#{user_combined_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*" + end + + def format_title(title) + title.lines.first.chomp + end + + def formatted_title + format_title(title) + end + + def create_issue_note(issue) + ["issue #{Issue.reference_prefix}#{issue[:iid]}", issue[:title]] + end + + def create_commit_note(commit) + commit_sha = Commit.truncate_sha(commit[:id]) + + ["commit #{commit_sha}", commit[:message]] + end + + def create_merge_note(merge_request) + ["merge request #{MergeRequest.reference_prefix}#{merge_request[:iid]}", merge_request[:title]] + end + + def create_snippet_note(snippet) + ["snippet #{Snippet.reference_prefix}#{snippet[:id]}", snippet[:title]] + end + + def description_message + [{ text: format(note), color: attachment_color }] + end + + def project_link + link(project_name, project_url) + end + end + end +end diff --git a/app/models/integrations/chat_message/pipeline_message.rb b/app/models/integrations/chat_message/pipeline_message.rb new file mode 100644 index 00000000000..a0f6f582e4c --- /dev/null +++ b/app/models/integrations/chat_message/pipeline_message.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class PipelineMessage < BaseMessage + MAX_VISIBLE_JOBS = 10 + + attr_reader :user + attr_reader :ref_type + attr_reader :ref + attr_reader :status + attr_reader :detailed_status + attr_reader :duration + attr_reader :finished_at + attr_reader :pipeline_id + attr_reader :failed_stages + attr_reader :failed_jobs + + attr_reader :project + attr_reader :commit + attr_reader :committer + attr_reader :pipeline + + def initialize(data) + super + + @user = data[:user] + @user_name = data.dig(:user, :username) || 'API' + + pipeline_attributes = data[:object_attributes] + @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' + @ref = pipeline_attributes[:ref] + @status = pipeline_attributes[:status] + @detailed_status = pipeline_attributes[:detailed_status] + @duration = pipeline_attributes[:duration].to_i + @finished_at = pipeline_attributes[:finished_at] ? Time.parse(pipeline_attributes[:finished_at]).to_i : nil + @pipeline_id = pipeline_attributes[:id] + + # Get list of jobs that have actually failed (after exhausting all retries) + @failed_jobs = actually_failed_jobs(Array(data[:builds])) + @failed_stages = @failed_jobs.map { |j| j[:stage] }.uniq + + @project = Project.find(data[:project][:id]) + @commit = project.commit_by(oid: data[:commit][:id]) + @committer = commit.committer + @pipeline = Ci::Pipeline.find(pipeline_id) + end + + def pretext + '' + end + + def attachments + return message if markdown + + [{ + fallback: format(message), + color: attachment_color, + author_name: user_combined_name, + author_icon: user_avatar, + author_link: author_url, + title: s_("ChatMessage|Pipeline #%{pipeline_id} %{humanized_status} in %{duration}") % + { + pipeline_id: pipeline_id, + humanized_status: humanized_status, + duration: pretty_duration(duration) + }, + title_link: pipeline_url, + fields: attachments_fields, + footer: project.name, + footer_icon: project.avatar_url(only_path: false), + ts: finished_at + }] + end + + def activity + { + title: s_("ChatMessage|Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status}") % + { + pipeline_link: pipeline_link, + ref_type: ref_type, + ref_link: ref_link, + user_combined_name: user_combined_name, + humanized_status: humanized_status + }, + subtitle: s_("ChatMessage|in %{project_link}") % { project_link: project_link }, + text: s_("ChatMessage|in %{duration}") % { duration: pretty_duration(duration) }, + image: user_avatar || '' + } + end + + private + + def actually_failed_jobs(builds) + succeeded_job_names = builds.map { |b| b[:name] if b[:status] == 'success' }.compact.uniq + + failed_jobs = builds.select do |build| + # Select jobs which doesn't have a successful retry + build[:status] == 'failed' && !succeeded_job_names.include?(build[:name]) + end + + failed_jobs.uniq { |job| job[:name] }.reverse + end + + def failed_stages_field + { + title: s_("ChatMessage|Failed stage").pluralize(failed_stages.length), + value: Slack::Messenger::Util::LinkFormatter.format(failed_stages_links), + short: true + } + end + + def failed_jobs_field + { + title: s_("ChatMessage|Failed job").pluralize(failed_jobs.length), + value: Slack::Messenger::Util::LinkFormatter.format(failed_jobs_links), + short: true + } + end + + def yaml_error_field + { + title: s_("ChatMessage|Invalid CI config YAML file"), + value: pipeline.yaml_errors, + short: false + } + end + + def attachments_fields + fields = [ + { + title: ref_type == "tag" ? s_("ChatMessage|Tag") : s_("ChatMessage|Branch"), + value: Slack::Messenger::Util::LinkFormatter.format(ref_link), + short: true + }, + { + title: s_("ChatMessage|Commit"), + value: Slack::Messenger::Util::LinkFormatter.format(commit_link), + short: true + } + ] + + fields << failed_stages_field if failed_stages.any? + fields << failed_jobs_field if failed_jobs.any? + fields << yaml_error_field if pipeline.has_yaml_errors? + + fields + end + + def message + s_("ChatMessage|%{project_link}: Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status} in %{duration}") % + { + project_link: project_link, + pipeline_link: pipeline_link, + ref_type: ref_type, + ref_link: ref_link, + user_combined_name: user_combined_name, + humanized_status: humanized_status, + duration: pretty_duration(duration) + } + end + + def humanized_status + case status + when 'success' + detailed_status == "passed with warnings" ? s_("ChatMessage|has passed with warnings") : s_("ChatMessage|has passed") + when 'failed' + s_("ChatMessage|has failed") + else + status + end + end + + def attachment_color + case status + when 'success' + detailed_status == 'passed with warnings' ? 'warning' : 'good' + else + 'danger' + end + end + + def ref_url + if ref_type == 'tag' + "#{project_url}/-/tags/#{ref}" + else + "#{project_url}/-/commits/#{ref}" + end + end + + def ref_link + "[#{ref}](#{ref_url})" + end + + def project_url + project.web_url + end + + def project_link + "[#{project.name}](#{project_url})" + end + + def pipeline_failed_jobs_url + "#{project_url}/-/pipelines/#{pipeline_id}/failures" + end + + def pipeline_url + if failed_jobs.any? + pipeline_failed_jobs_url + else + "#{project_url}/-/pipelines/#{pipeline_id}" + end + end + + def pipeline_link + "[##{pipeline_id}](#{pipeline_url})" + end + + def job_url(job) + "#{project_url}/-/jobs/#{job[:id]}" + end + + def job_link(job) + "[#{job[:name]}](#{job_url(job)})" + end + + def failed_jobs_links + failed = failed_jobs.slice(0, MAX_VISIBLE_JOBS) + truncated = failed_jobs.slice(MAX_VISIBLE_JOBS, failed_jobs.size) + + failed_links = failed.map { |job| job_link(job) } + + unless truncated.blank? + failed_links << s_("ChatMessage|and [%{count} more](%{pipeline_failed_jobs_url})") % { + count: truncated.size, + pipeline_failed_jobs_url: pipeline_failed_jobs_url + } + end + + failed_links.join(I18n.t(:'support.array.words_connector')) + end + + def stage_link(stage) + # All stages link to the pipeline page + "[#{stage}](#{pipeline_url})" + end + + def failed_stages_links + failed_stages.map { |s| stage_link(s) }.join(I18n.t(:'support.array.words_connector')) + end + + def commit_url + Gitlab::UrlBuilder.build(commit) + end + + def commit_link + "[#{commit.title}](#{commit_url})" + end + + def author_url + return unless user && committer + + Gitlab::UrlBuilder.build(committer) + end + end + end +end diff --git a/app/models/integrations/chat_message/push_message.rb b/app/models/integrations/chat_message/push_message.rb new file mode 100644 index 00000000000..0952986e923 --- /dev/null +++ b/app/models/integrations/chat_message/push_message.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class PushMessage < BaseMessage + attr_reader :after + attr_reader :before + attr_reader :commits + attr_reader :ref + attr_reader :ref_type + + def initialize(params) + super + + @after = params[:after] + @before = params[:before] + @commits = params.fetch(:commits, []) + @ref_type = Gitlab::Git.tag_ref?(params[:ref]) ? 'tag' : 'branch' + @ref = Gitlab::Git.ref_name(params[:ref]) + end + + def attachments + return [] if new_branch? || removed_branch? + return commit_messages if markdown + + commit_message_attachments + end + + def activity + { + title: humanized_action(short: true), + subtitle: "in #{project_link}", + text: compare_link, + image: user_avatar + } + end + + private + + def humanized_action(short: false) + action, ref_link, target_link = compose_action_details + text = [user_combined_name, action, ref_type, ref_link] + text << target_link unless short + text.join(' ') + end + + def message + humanized_action + end + + def format(string) + Slack::Messenger::Util::LinkFormatter.format(string) + end + + def commit_messages + commits.map { |commit| compose_commit_message(commit) }.join("\n\n") + end + + def commit_message_attachments + [{ text: format(commit_messages), color: attachment_color }] + end + + def compose_commit_message(commit) + author = commit[:author][:name] + id = Commit.truncate_sha(commit[:id]) + title = commit[:title] + + url = commit[:url] + + "[#{id}](#{url}): #{title} - #{author}" + end + + def new_branch? + Gitlab::Git.blank_ref?(before) + end + + def removed_branch? + Gitlab::Git.blank_ref?(after) + end + + def ref_url + if ref_type == 'tag' + "#{project_url}/-/tags/#{ref}" + else + "#{project_url}/commits/#{ref}" + end + end + + def compare_url + "#{project_url}/compare/#{before}...#{after}" + end + + def ref_link + "[#{ref}](#{ref_url})" + end + + def project_link + "[#{project_name}](#{project_url})" + end + + def compare_link + "[Compare changes](#{compare_url})" + end + + def compose_action_details + if new_branch? + ['pushed new', ref_link, "to #{project_link}"] + elsif removed_branch? + ['removed', ref, "from #{project_link}"] + else + ['pushed to', ref_link, "of #{project_link} (#{compare_link})"] + end + end + + def attachment_color + '#345' + end + end + end +end diff --git a/app/models/integrations/chat_message/wiki_page_message.rb b/app/models/integrations/chat_message/wiki_page_message.rb new file mode 100644 index 00000000000..9b5275b8c03 --- /dev/null +++ b/app/models/integrations/chat_message/wiki_page_message.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class WikiPageMessage < BaseMessage + attr_reader :title + attr_reader :wiki_page_url + attr_reader :action + attr_reader :description + + def initialize(params) + super + + obj_attr = params[:object_attributes] + obj_attr = HashWithIndifferentAccess.new(obj_attr) + @title = obj_attr[:title] + @wiki_page_url = obj_attr[:url] + @description = obj_attr[:message] + + @action = + case obj_attr[:action] + when "create" + "created" + when "update" + "edited" + end + end + + def attachments + return description if markdown + + description_message + end + + def activity + { + title: "#{user_combined_name} #{action} #{wiki_page_link}", + subtitle: "in #{project_link}", + text: title, + image: user_avatar + } + end + + private + + def message + "#{user_combined_name} #{action} #{wiki_page_link} in #{project_link}: *#{title}*" + end + + def description_message + [{ text: format(@description), color: attachment_color }] + end + + def project_link + "[#{project_name}](#{project_url})" + end + + def wiki_page_link + "[wiki page](#{wiki_page_url})" + end + end + end +end diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb new file mode 100644 index 00000000000..30f73496993 --- /dev/null +++ b/app/models/integrations/confluence.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Integrations + class Confluence < Integration + include ActionView::Helpers::UrlHelper + + VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze + VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze + VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze + + prop_accessor :confluence_url + + validates :confluence_url, presence: true, if: :activated? + validate :validate_confluence_url_is_cloud, if: :activated? + + after_commit :cache_project_has_confluence + + def self.to_param + 'confluence' + end + + def self.supported_events + %w() + end + + def title + s_('ConfluenceService|Confluence Workspace') + end + + def description + s_('ConfluenceService|Link to a Confluence Workspace from the sidebar.') + end + + def help + return unless project&.wiki_enabled? + + if activated? + wiki_url = project.wiki.web_url + + s_( + 'ConfluenceService|Your GitLab wiki is still available at %{wiki_link}. To re-enable the link to the GitLab wiki, disable this integration.' % + { wiki_link: link_to(wiki_url, wiki_url) } + ).html_safe + else + s_('ConfluenceService|Link to a Confluence Workspace from the sidebar. Enabling this integration replaces the "Wiki" sidebar link with a link to the Confluence Workspace. The GitLab wiki is still available at the original URL.').html_safe + end + end + + def fields + [ + { + type: 'text', + name: 'confluence_url', + title: s_('Confluence Cloud Workspace URL'), + placeholder: 'https://example.atlassian.net/wiki', + required: true + } + ] + end + + def can_test? + false + end + + private + + def validate_confluence_url_is_cloud + unless confluence_uri_valid? + errors.add(:confluence_url, 'URL must be to a Confluence Cloud Workspace hosted on atlassian.net') + end + end + + def confluence_uri_valid? + return false unless confluence_url + + uri = URI.parse(confluence_url) + + (uri.scheme&.match(VALID_SCHEME_MATCH) && + uri.host&.match(VALID_HOST_MATCH) && + uri.path&.match(VALID_PATH_MATCH)).present? + + rescue URI::InvalidURIError + false + end + + def cache_project_has_confluence + return unless project && !project.destroyed? + + project.project_setting.save! unless project.project_setting.persisted? + project.project_setting.update_column(:has_confluence, active?) + end + end +end diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb new file mode 100644 index 00000000000..dd4b0664d52 --- /dev/null +++ b/app/models/integrations/datadog.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module Integrations + class Datadog < Integration + DEFAULT_SITE = 'datadoghq.com' + URL_TEMPLATE = 'https://webhooks-http-intake.logs.%{datadog_site}/v1/input/' + URL_TEMPLATE_API_KEYS = 'https://app.%{datadog_site}/account/settings#api' + URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_SITE}/account_management/api-app-keys/" + + SUPPORTED_EVENTS = %w[ + pipeline job + ].freeze + + prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env + + with_options if: :activated? do + validates :api_key, presence: true, format: { with: /\A\w+\z/ } + validates :datadog_site, format: { with: /\A[\w\.]+\z/, allow_blank: true } + validates :api_url, public_url: { allow_blank: true } + validates :datadog_site, presence: true, unless: -> (obj) { obj.api_url.present? } + validates :api_url, presence: true, unless: -> (obj) { obj.datadog_site.present? } + end + + after_save :compose_service_hook, if: :activated? + + def initialize_properties + super + + self.datadog_site ||= DEFAULT_SITE + end + + def self.supported_events + SUPPORTED_EVENTS + end + + def self.default_test_event + 'pipeline' + end + + def configurable_events + [] # do not allow to opt out of required hooks + end + + def title + 'Datadog' + end + + def description + 'Trace your GitLab pipelines with Datadog' + end + + def help + nil + end + + def self.to_param + 'datadog' + end + + def fields + [ + { + type: 'text', + name: 'datadog_site', + placeholder: DEFAULT_SITE, + help: 'Choose the Datadog site to send data to. Set to "datadoghq.eu" to send data to the EU site', + required: false + }, + { + type: 'text', + name: 'api_url', + title: 'API URL', + help: '(Advanced) Define the full URL for your Datadog site directly', + required: false + }, + { + type: 'password', + name: 'api_key', + title: _('API key'), + non_empty_password_title: s_('ProjectService|Enter new API key'), + non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'), + help: "<a href=\"#{api_keys_url}\" target=\"_blank\">API key</a> used for authentication with Datadog", + required: true + }, + { + type: 'text', + name: 'datadog_service', + title: 'Service', + placeholder: 'gitlab-ci', + help: 'Name of this GitLab instance that all data will be tagged with' + }, + { + type: 'text', + name: 'datadog_env', + title: 'Env', + help: 'The environment tag that traces will be tagged with' + } + ] + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = hook_url + hook.save + end + + def hook_url + url = api_url.presence || sprintf(URL_TEMPLATE, datadog_site: datadog_site) + url = URI.parse(url) + url.path = File.join(url.path || '/', api_key) + query = { service: datadog_service.presence, env: datadog_env.presence }.compact + url.query = query.to_query unless query.empty? + url.to_s + end + + def api_keys_url + return URL_API_KEYS_DOCS unless datadog_site.presence + + sprintf(URL_TEMPLATE_API_KEYS, datadog_site: datadog_site) + end + + def execute(data) + return if project.disabled_services.include?(to_param) + + object_kind = data[:object_kind] + object_kind = 'job' if object_kind == 'build' + return unless supported_events.include?(object_kind) + + service_hook.execute(data, "#{object_kind} hook") + end + + def test(data) + begin + result = execute(data) + return { success: false, result: result[:message] } if result[:http_status] != 200 + rescue StandardError => error + return { success: false, result: error } + end + + { success: true, result: result[:message] } + end + end +end diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb new file mode 100644 index 00000000000..e277633664f --- /dev/null +++ b/app/models/integrations/emails_on_push.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Integrations + class EmailsOnPush < Integration + include NotificationBranchSelection + + RECIPIENTS_LIMIT = 750 + + boolean_accessor :send_from_committer_email + boolean_accessor :disable_diffs + prop_accessor :recipients, :branches_to_be_notified + validates :recipients, presence: true, if: :validate_recipients? + validate :number_of_recipients_within_limit, if: :validate_recipients? + + def self.valid_recipients(recipients) + recipients.split.select do |recipient| + recipient.include?('@') + end.uniq(&:downcase) + end + + def title + s_('EmailsOnPushService|Emails on push') + end + + def description + s_('EmailsOnPushService|Email the commits and diff of each push to a list of recipients.') + end + + def self.to_param + 'emails_on_push' + end + + def self.supported_events + %w(push tag_push) + end + + def initialize_properties + super + + self.branches_to_be_notified = 'all' if branches_to_be_notified.nil? + end + + def execute(push_data) + return unless supported_events.include?(push_data[:object_kind]) + return if project.emails_disabled? + return unless notify_for_ref?(push_data) + + EmailsOnPushWorker.perform_async( + project_id, + recipients, + push_data, + send_from_committer_email: send_from_committer_email?, + disable_diffs: disable_diffs? + ) + end + + def notify_for_ref?(push_data) + return true if push_data[:object_kind] == 'tag_push' + return true if push_data.dig(:object_attributes, :tag) + + notify_for_branch?(push_data) + end + + def send_from_committer_email? + Gitlab::Utils.to_boolean(self.send_from_committer_email) + end + + def disable_diffs? + Gitlab::Utils.to_boolean(self.disable_diffs) + end + + def fields + domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ") + [ + { type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"), + help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } }, + { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), + help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }, + { + type: 'textarea', + name: 'recipients', + placeholder: s_('EmailsOnPushService|tanuki@example.com gitlab@example.com'), + help: s_('EmailsOnPushService|Emails separated by whitespace.') + } + ] + end + + private + + def number_of_recipients_within_limit + return if recipients.blank? + + if self.class.valid_recipients(recipients).size > RECIPIENTS_LIMIT + errors.add(:recipients, s_("EmailsOnPushService|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT }) + end + end + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index af78466e6a9..2077f9bfdbb 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -87,7 +87,8 @@ class Issue < ApplicationRecord enum issue_type: { issue: 0, incident: 1, - test_case: 2 ## EE-only + test_case: 2, ## EE-only + requirement: 3 ## EE-only } alias_method :issuing_parent, :project @@ -108,6 +109,7 @@ class Issue < ApplicationRecord scope :order_due_date_desc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) } scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) } scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) } + scope :order_relative_position_desc, -> { reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')) } scope :order_closed_date_desc, -> { reorder(closed_at: :desc) } scope :order_created_at_desc, -> { reorder(created_at: :desc) } scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') } @@ -121,7 +123,7 @@ class Issue < ApplicationRecord scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) } scope :with_self_managed_prometheus_alert_events, -> { joins(:issues_self_managed_prometheus_alert_events) } scope :with_api_entity_associations, -> { - preload(:timelogs, :closed_by, :assignees, :author, :notes, :labels, + preload(:timelogs, :closed_by, :assignees, :author, :labels, milestone: { project: [:route, { namespace: :route }] }, project: [:route, { namespace: :route }]) } @@ -174,8 +176,16 @@ class Issue < ApplicationRecord state :opened, value: Issue.available_states[:opened] state :closed, value: Issue.available_states[:closed] - before_transition any => :closed do |issue| + before_transition any => :closed do |issue, transition| + args = transition.args + issue.closed_at = issue.system_note_timestamp + + next if args.empty? + + next unless args.first.is_a?(User) + + issue.closed_by = args.first end before_transition closed: :opened do |issue| @@ -262,6 +272,18 @@ class Issue < ApplicationRecord "id DESC") end + # Temporary disable moving null elements because of performance problems + # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321 + def check_repositioning_allowed! + if blocked_for_repositioning? + raise ::Gitlab::RelativePositioning::IssuePositioningDisabled, "Issue relative position changes temporarily disabled." + end + end + + def blocked_for_repositioning? + resource_parent.root_namespace&.issue_repositioning_disabled? + end + def hook_attrs Gitlab::HookData::IssueBuilder.new(self).build end @@ -506,4 +528,4 @@ class Issue < ApplicationRecord end end -Issue.prepend_if_ee('EE::Issue') +Issue.prepend_mod_with('Issue') diff --git a/app/models/issue/metrics.rb b/app/models/issue/metrics.rb index a5e1957c096..86523bbd023 100644 --- a/app/models/issue/metrics.rb +++ b/app/models/issue/metrics.rb @@ -24,6 +24,10 @@ class Issue::Metrics < ApplicationRecord private def issue_assigned_to_list_label? - issue.labels.any? { |label| label.lists.present? } + # Avoid another DB lookup when issue.labels are empty by adding a guard clause here + # We can't use issue.labels.empty? because that will cause a `Label Exists?` DB lookup + return false if issue.labels.length == 0 # rubocop:disable Style/ZeroLengthPredicate + + issue.labels.includes(:lists).any? { |label| label.lists.present? } end end diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb index d62f0eb170c..d8fbd49d313 100644 --- a/app/models/issue_assignee.rb +++ b/app/models/issue_assignee.rb @@ -8,9 +8,9 @@ class IssueAssignee < ApplicationRecord validates :assignee, uniqueness: { scope: :issue_id } - scope :in_projects, ->(project_ids) { joins(:issue).where("issues.project_id in (?)", project_ids) } + scope :in_projects, ->(project_ids) { joins(:issue).where(issues: { project_id: project_ids }) } scope :on_issues, ->(issue_ids) { where(issue_id: issue_ids) } scope :for_assignee, ->(user) { where(assignee: user) } end -IssueAssignee.prepend_if_ee('EE::IssueAssignee') +IssueAssignee.prepend_mod_with('IssueAssignee') diff --git a/app/models/issue_link.rb b/app/models/issue_link.rb index ba97874ed39..920586cc1ba 100644 --- a/app/models/issue_link.rb +++ b/app/models/issue_link.rb @@ -46,4 +46,4 @@ class IssueLink < ApplicationRecord end end -IssueLink.prepend_if_ee('EE::IssueLink') +IssueLink.prepend_mod_with('IssueLink') diff --git a/app/models/iteration.rb b/app/models/iteration.rb index 7483d04aab8..71ecbcf1c1a 100644 --- a/app/models/iteration.rb +++ b/app/models/iteration.rb @@ -13,4 +13,4 @@ class Iteration < ApplicationRecord end end -Iteration.prepend_if_ee('::EE::Iteration') +Iteration.prepend_mod_with('Iteration') diff --git a/app/models/key.rb b/app/models/key.rb index 131416d1bee..15b3c460b52 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -147,4 +147,4 @@ class Key < ApplicationRecord end end -Key.prepend_if_ee('EE::Key') +Key.prepend_mod_with('Key') diff --git a/app/models/label.rb b/app/models/label.rb index 26faaa90df3..a46d6bc5c0f 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -290,4 +290,4 @@ class Label < ApplicationRecord end end -Label.prepend_if_ee('EE::Label') +Label.prepend_mod_with('Label') diff --git a/app/models/label_link.rb b/app/models/label_link.rb index 5ae1e88e14e..a466fe69300 100644 --- a/app/models/label_link.rb +++ b/app/models/label_link.rb @@ -9,4 +9,7 @@ class LabelLink < ApplicationRecord validates :target, presence: true, unless: :importing? validates :label, presence: true, unless: :importing? + + scope :for_target, -> (target_id, target_type) { where(target_id: target_id, target_type: target_type) } + scope :with_remove_on_close_labels, -> { joins(:label).where(labels: { remove_on_close: true }) } end diff --git a/app/models/label_note.rb b/app/models/label_note.rb index e90028ce835..19dede36abd 100644 --- a/app/models/label_note.rb +++ b/app/models/label_note.rb @@ -79,4 +79,4 @@ class LabelNote < SyntheticNote end end -LabelNote.prepend_if_ee('EE::LabelNote') +LabelNote.prepend_mod_with('LabelNote') diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index df1ad8ea281..25e90036a53 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -112,4 +112,4 @@ class LegacyDiffNote < Note end end -LegacyDiffNote.prepend_if_ee('EE::LegacyDiffNote') +LegacyDiffNote.prepend_mod_with('LegacyDiffNote') diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index d60baa299cb..b837b902e2d 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -50,4 +50,4 @@ class LfsObject < ApplicationRecord end end -LfsObject.prepend_if_ee('EE::LfsObject') +LfsObject.prepend_mod_with('LfsObject') diff --git a/app/models/list.rb b/app/models/list.rb index d72afbaee69..fba0e51bdf8 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -49,4 +49,4 @@ class List < ApplicationRecord end end -List.prepend_if_ee('::EE::List') +List.prepend_mod_with('List') diff --git a/app/models/member.rb b/app/models/member.rb index e978552592d..044b662e10f 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -84,15 +84,25 @@ class Member < ApplicationRecord is_external_invite = arel_table[:user_id].eq(nil).and(arel_table[:invite_token].not_eq(nil)) user_is_blocked = User.arel_table[:state].eq(:blocked) - user_ok = Arel::Nodes::Grouping.new(is_external_invite).or(user_is_blocked) - left_join_users - .where(user_ok) + .where(user_is_blocked) + .where.not(is_external_invite) .non_request .non_minimal_access .reorder(nil) end + scope :connected_to_user, -> { where.not(user_id: nil) } + + # This scope is exclusively used to get the members + # that can possibly have project_authorization records + # to projects/groups. + scope :authorizable, -> do + connected_to_user + .non_request + .non_minimal_access + end + # Like active, but without invites. For when a User is required. scope :active_without_invites_and_requests, -> do left_join_users @@ -140,7 +150,8 @@ class Member < ApplicationRecord scope :distinct_on_user_with_max_access_level, -> do distinct_members = select('DISTINCT ON (user_id, invite_email) *') .order('user_id, invite_email, access_level DESC, expires_at DESC, created_at ASC') - Member.from(distinct_members, :members) + + from(distinct_members, :members) end scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) } @@ -560,4 +571,4 @@ class Member < ApplicationRecord end end -Member.prepend_if_ee('EE::Member') +Member.prepend_mod_with('Member') diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 0f9fdd230ff..b22a4fa9ef6 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -101,4 +101,4 @@ class GroupMember < Member end end -GroupMember.prepend_if_ee('EE::GroupMember') +GroupMember.prepend_mod_with('GroupMember') diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 9a86b3a3fd9..41ecc4cbf01 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -16,7 +16,7 @@ class ProjectMember < Member scope :in_project, ->(project) { where(source_id: project.id) } scope :in_namespaces, ->(groups) do joins('INNER JOIN projects ON projects.id = members.source_id') - .where('projects.namespace_id in (?)', groups.select(:id)) + .where(projects: { namespace_id: groups.select(:id) }) end scope :without_project_bots, -> do @@ -69,7 +69,7 @@ class ProjectMember < Member end true - rescue + rescue StandardError false end @@ -154,4 +154,4 @@ class ProjectMember < Member # rubocop: enable CodeReuse/ServiceClass end -ProjectMember.prepend_if_ee('EE::ProjectMember') +ProjectMember.prepend_mod_with('ProjectMember') diff --git a/app/models/members_preloader.rb b/app/models/members_preloader.rb index 88db7f63bd9..ba7e4b39989 100644 --- a/app/models/members_preloader.rb +++ b/app/models/members_preloader.rb @@ -10,10 +10,11 @@ class MembersPreloader def preload_all ActiveRecord::Associations::Preloader.new.preload(members, :user) ActiveRecord::Associations::Preloader.new.preload(members, :source) - ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :status) - ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :u2f_registrations) - ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :webauthn_registrations) + ActiveRecord::Associations::Preloader.new.preload(members, :created_by) + ActiveRecord::Associations::Preloader.new.preload(members, user: :status) + ActiveRecord::Associations::Preloader.new.preload(members, user: :u2f_registrations) + ActiveRecord::Associations::Preloader.new.preload(members, user: :webauthn_registrations) if Feature.enabled?(:webauthn) end end -MembersPreloader.prepend_if_ee('EE::MembersPreloader') +MembersPreloader.prepend_mod_with('MembersPreloader') diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index e7f3762b9a3..aaef56418d2 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -37,6 +37,7 @@ class MergeRequest < ApplicationRecord SORTING_PREFERENCE_FIELD = :merge_requests_sort ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON = { + 'Ci::CompareMetricsReportsService' => ->(project) { ::Gitlab::Ci::Features.merge_base_pipeline_for_metrics_comparison?(project) }, 'Ci::CompareCodequalityReportsService' => ->(project) { true } }.freeze @@ -381,7 +382,7 @@ class MergeRequest < ApplicationRecord scope :review_requested_to, ->(user) do where( reviewers_subquery - .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user)) + .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user.id)) .exists ) end @@ -389,7 +390,7 @@ class MergeRequest < ApplicationRecord scope :no_review_requested_to, ->(user) do where( reviewers_subquery - .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user)) + .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user.id)) .exists .not ) @@ -1367,11 +1368,11 @@ class MergeRequest < ApplicationRecord def environments_for(current_user, latest: false) return [] unless diff_head_commit - envs = EnvironmentsByDeploymentsFinder.new(target_project, current_user, + envs = Environments::EnvironmentsByDeploymentsFinder.new(target_project, current_user, ref: target_branch, commit: diff_head_commit, with_tags: true, find_latest: latest).execute if source_project - envs.concat EnvironmentsByDeploymentsFinder.new(source_project, current_user, + envs.concat Environments::EnvironmentsByDeploymentsFinder.new(source_project, current_user, ref: source_branch, commit: diff_head_commit, find_latest: latest).execute end @@ -1741,7 +1742,7 @@ class MergeRequest < ApplicationRecord if project.resolve_outdated_diff_discussions? MergeRequests::ResolvedDiscussionNotificationService - .new(project, current_user) + .new(project: project, current_user: current_user) .execute(self) end end @@ -1899,6 +1900,12 @@ class MergeRequest < ApplicationRecord diff_stats.map(&:path).include?(project.ci_config_path_or_default) end + def context_commits_diff + strong_memoize(:context_commits_diff) do + ContextCommitsDiff.new(self) + end + end + private def missing_report_error(report_type) @@ -1948,4 +1955,4 @@ class MergeRequest < ApplicationRecord end end -MergeRequest.prepend_if_ee('::EE::MergeRequest') +MergeRequest.prepend_mod_with('MergeRequest') diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index 5c611da0684..b9460afa8e7 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -31,4 +31,4 @@ class MergeRequest::Metrics < ApplicationRecord end end -MergeRequest::Metrics.prepend_if_ee('EE::MergeRequest::Metrics') +MergeRequest::Metrics.prepend_mod_with('MergeRequest::Metrics') diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb index 73f8fe77b04..86bf950ae19 100644 --- a/app/models/merge_request_assignee.rb +++ b/app/models/merge_request_assignee.rb @@ -6,5 +6,5 @@ class MergeRequestAssignee < ApplicationRecord validates :assignee, uniqueness: { scope: :merge_request_id } - scope :in_projects, ->(project_ids) { joins(:merge_request).where("merge_requests.target_project_id in (?)", project_ids) } + scope :in_projects, ->(project_ids) { joins(:merge_request).where(merge_requests: { target_project_id: project_ids }) } end diff --git a/app/models/merge_request_context_commit_diff_file.rb b/app/models/merge_request_context_commit_diff_file.rb index 6f15df1b70f..8abedd26b06 100644 --- a/app/models/merge_request_context_commit_diff_file.rb +++ b/app/models/merge_request_context_commit_diff_file.rb @@ -16,4 +16,8 @@ class MergeRequestContextCommitDiffFile < ApplicationRecord def self.bulk_insert(*args) Gitlab::Database.bulk_insert('merge_request_context_commit_diff_files', *args) # rubocop:disable Gitlab/BulkInsert end + + def path + new_path.presence || old_path + end end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index fb873ddbbab..2dc6796732f 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -113,14 +113,29 @@ class MergeRequestDiff < ApplicationRecord joins(merge_request: :metrics).where(condition) end + # This scope uses LATERAL JOIN to find the most recent MR diff association for the given merge requests. + # To avoid joining the merge_requests table, we build an in memory table using the merge request ids. + # Example: + # SELECT ... + # FROM (VALUES (MR_ID_1),(MR_ID_2)) merge_requests (id) + # INNER JOIN LATERAL (...) scope :latest_diff_for_merge_requests, -> (merge_requests) do - inner_select = MergeRequestDiff - .default_scoped - .distinct - .select("FIRST_VALUE(id) OVER (PARTITION BY merge_request_id ORDER BY created_at DESC) as id") - .where(merge_request: merge_requests) + mrs = Array(merge_requests) + return MergeRequestDiff.none if mrs.empty? - joins("INNER JOIN (#{inner_select.to_sql}) latest_diffs ON latest_diffs.id = merge_request_diffs.id") + merge_request_table = MergeRequest.arel_table + merge_request_diff_table = MergeRequestDiff.arel_table + + join_query = MergeRequestDiff + .where(merge_request_table[:id].eq(merge_request_diff_table[:merge_request_id])) + .order(created_at: :desc) + .limit(1) + + mr_id_list = mrs.map { |mr| "(#{Integer(mr.id)})" }.join(",") + + MergeRequestDiff + .from("(VALUES #{mr_id_list}) merge_requests (id)") + .joins("INNER JOIN LATERAL (#{join_query.to_sql}) #{MergeRequestDiff.table_name} ON TRUE") .includes(:merge_request_diff_commits) end @@ -665,10 +680,6 @@ class MergeRequestDiff < ApplicationRecord opening_external_diff do collection = merge_request_diff_files - if options[:include_context_commits] - collection += merge_request.merge_request_context_commit_diff_files - end - if paths = options[:paths] collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths) end @@ -743,7 +754,6 @@ class MergeRequestDiff < ApplicationRecord end def reorder_diff_files! - return unless sort_diffs? return if sorted? || merge_request_diff_files.empty? diff_files = sort_diffs(merge_request_diff_files) @@ -762,14 +772,8 @@ class MergeRequestDiff < ApplicationRecord end def sort_diffs(diffs) - return diffs unless sort_diffs? - Gitlab::Diff::FileCollectionSorter.new(diffs).sort end - - def sort_diffs? - Feature.enabled?(:sort_diffs, project, default_enabled: :yaml) - end end -MergeRequestDiff.prepend_if_ee('EE::MergeRequestDiff') +MergeRequestDiff.prepend_mod_with('MergeRequestDiff') diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 4cf0e423a15..16090f0ebfa 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -7,7 +7,7 @@ class Milestone < ApplicationRecord include FromUnion include Importable - prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule + prepend_mod_with('Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule class Predefined ALL = [::Timebox::None, ::Timebox::Any, ::Timebox::Started, ::Timebox::Upcoming].freeze @@ -94,7 +94,7 @@ class Milestone < ApplicationRecord end def participants - User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).distinct + User.joins(assigned_issues: :milestone).where(milestones: { id: id }).distinct end def self.sort_by_attribute(method) diff --git a/app/models/milestone_release.rb b/app/models/milestone_release.rb index c6b5a967af9..93ad961ca51 100644 --- a/app/models/milestone_release.rb +++ b/app/models/milestone_release.rb @@ -19,4 +19,4 @@ class MilestoneRelease < ApplicationRecord end end -MilestoneRelease.prepend_if_ee('EE::MilestoneRelease') +MilestoneRelease.prepend_mod_with('MilestoneRelease') diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 455429608b4..8f03c6145cb 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -14,6 +14,7 @@ class Namespace < ApplicationRecord include IgnorableColumns include Namespaces::Traversal::Recursive include Namespaces::Traversal::Linear + include EachBatch ignore_column :delayed_project_removal, remove_with: '14.1', remove_after: '2021-05-22' @@ -88,8 +89,12 @@ class Namespace < ApplicationRecord after_update :move_dir, if: :saved_change_to_path_or_parent? before_destroy(prepend: true) { prepare_for_destroy } after_destroy :rm_dir + after_commit :expire_child_caches, on: :update, if: -> { + Feature.enabled?(:cached_route_lookups, self, type: :ops, default_enabled: :yaml) && + saved_change_to_name? || saved_change_to_path? || saved_change_to_parent_id? + } - scope :for_user, -> { where('type IS NULL') } + scope :for_user, -> { where(type: nil) } scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) } scope :include_route, -> { includes(:route) } scope :by_parent, -> (parent) { where(parent_id: parent) } @@ -198,7 +203,7 @@ class Namespace < ApplicationRecord end def any_project_has_container_registry_tags? - all_projects.any?(&:has_container_registry_tags?) + all_projects.includes(:container_repositories).any?(&:has_container_registry_tags?) end def first_project_with_container_registry_tags @@ -420,8 +425,22 @@ class Namespace < ApplicationRecord created_at >= 90.days.ago end + def issue_repositioning_disabled? + Feature.enabled?(:block_issue_repositioning, self, type: :ops, default_enabled: :yaml) + end + private + def expire_child_caches + Namespace.where(id: descendants).each_batch do |namespaces| + namespaces.touch_all + end + + all_projects.each_batch do |projects| + projects.touch_all + end + end + def all_projects_with_pages if all_projects.pages_metadata_not_migrated.exists? Gitlab::BackgroundMigration::MigratePagesMetadata.new.perform_on_relation( @@ -490,4 +509,4 @@ class Namespace < ApplicationRecord end end -Namespace.prepend_if_ee('EE::Namespace') +Namespace.prepend_mod_with('Namespace') diff --git a/app/models/namespace/package_setting.rb b/app/models/namespace/package_setting.rb index a2064e020b3..881b2f3acb3 100644 --- a/app/models/namespace/package_setting.rb +++ b/app/models/namespace/package_setting.rb @@ -6,13 +6,15 @@ class Namespace::PackageSetting < ApplicationRecord PackageSettingNotImplemented = Class.new(StandardError) - PACKAGES_WITH_SETTINGS = %w[maven].freeze + PACKAGES_WITH_SETTINGS = %w[maven generic].freeze belongs_to :namespace, inverse_of: :package_setting_relation validates :namespace, presence: true validates :maven_duplicates_allowed, inclusion: { in: [true, false] } validates :maven_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } + validates :generic_duplicates_allowed, inclusion: { in: [true, false] } + validates :generic_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } class << self def duplicates_allowed?(package) @@ -22,7 +24,7 @@ class Namespace::PackageSetting < ApplicationRecord duplicates_allowed = package.package_settings["#{package.package_type}_duplicates_allowed"] regex = ::Gitlab::UntrustedRegexp.new("\\A#{package.package_settings["#{package.package_type}_duplicate_exception_regex"]}\\z") - duplicates_allowed || regex.match?(package.name) + duplicates_allowed || regex.match?(package.name) || regex.match?(package.version) end end end diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb index 0c91ae760b2..73061b78637 100644 --- a/app/models/namespace/root_storage_statistics.rb +++ b/app/models/namespace/root_storage_statistics.rb @@ -70,4 +70,4 @@ class Namespace::RootStorageStatistics < ApplicationRecord end end -Namespace::RootStorageStatistics.prepend_if_ee('EE::Namespace::RootStorageStatistics') +Namespace::RootStorageStatistics.prepend_mod_with('Namespace::RootStorageStatistics') diff --git a/app/models/namespace/traversal_hierarchy.rb b/app/models/namespace/traversal_hierarchy.rb index 28cf55f7486..093b7dae246 100644 --- a/app/models/namespace/traversal_hierarchy.rb +++ b/app/models/namespace/traversal_hierarchy.rb @@ -20,7 +20,7 @@ class Namespace end def initialize(root) - raise StandardError.new('Must specify a root node') if root.parent_id + raise StandardError, 'Must specify a root node' if root.parent_id @root = root end @@ -34,20 +34,23 @@ class Namespace sql = """ UPDATE namespaces SET traversal_ids = cte.traversal_ids - FROM (#{recursive_traversal_ids(lock: true)}) as cte + FROM (#{recursive_traversal_ids}) as cte WHERE namespaces.id = cte.id AND namespaces.traversal_ids <> cte.traversal_ids """ - Namespace.connection.exec_query(sql) + Namespace.transaction do + @root.lock! + Namespace.connection.exec_query(sql) + end rescue ActiveRecord::Deadlocked db_deadlock_counter.increment(source: 'Namespace#sync_traversal_ids!') raise end # Identify all incorrect traversal_ids in the current namespace hierarchy. - def incorrect_traversal_ids(lock: false) + def incorrect_traversal_ids Namespace - .joins("INNER JOIN (#{recursive_traversal_ids(lock: lock)}) as cte ON namespaces.id = cte.id") + .joins("INNER JOIN (#{recursive_traversal_ids}) as cte ON namespaces.id = cte.id") .where('namespaces.traversal_ids <> cte.traversal_ids') end @@ -58,13 +61,10 @@ class Namespace # # Note that the traversal_ids represent a calculated traversal path for the # namespace and not the value stored within the traversal_ids attribute. - # - # Optionally locked with FOR UPDATE to ensure isolation between concurrent - # updates of the heirarchy. - def recursive_traversal_ids(lock: false) + def recursive_traversal_ids root_id = Integer(@root.id) - sql = <<~SQL + <<~SQL WITH RECURSIVE cte(id, traversal_ids, cycle) AS ( VALUES(#{root_id}, ARRAY[#{root_id}], false) UNION ALL @@ -74,10 +74,6 @@ class Namespace ) SELECT id, traversal_ids FROM cte SQL - - sql += ' FOR UPDATE' if lock - - sql end # This is essentially Namespace#root_ancestor which will soon be rewritten diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index d21f9632e18..75b8169b58e 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -45,4 +45,4 @@ class NamespaceSetting < ApplicationRecord end end -NamespaceSetting.prepend_if_ee('EE::NamespaceSetting') +NamespaceSetting.prepend_mod_with('NamespaceSetting') diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index 294ef83b9b4..a1711bc5ee0 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -41,6 +41,7 @@ module Namespaces UnboundedSearch = Class.new(StandardError) included do + before_update :lock_both_roots, if: -> { sync_traversal_ids? && parent_id_changed? } after_create :sync_traversal_ids, if: -> { sync_traversal_ids? } after_update :sync_traversal_ids, if: -> { sync_traversal_ids? && saved_change_to_parent_id? } @@ -52,15 +53,30 @@ module Namespaces end def use_traversal_ids? - Feature.enabled?(:use_traversal_ids, root_ancestor, default_enabled: :yaml) + return false unless Feature.enabled?(:use_traversal_ids, root_ancestor, default_enabled: :yaml) + + traversal_ids.present? end def self_and_descendants - if use_traversal_ids? - lineage(self) - else - super - end + return super unless use_traversal_ids? + + lineage(top: self) + end + + def descendants + return super unless use_traversal_ids? + + self_and_descendants.where.not(id: id) + end + + def ancestors(hierarchy_order: nil) + return super() unless use_traversal_ids? + return super() unless Feature.enabled?(:use_traversal_ids_for_ancestors, root_ancestor, default_enabled: :yaml) + + return self.class.none if parent_id.blank? + + lineage(bottom: parent, hierarchy_order: hierarchy_order) end private @@ -75,6 +91,23 @@ module Namespaces Namespace::TraversalHierarchy.for_namespace(root_ancestor).sync_traversal_ids! end + # Lock the root of the hierarchy we just left, and lock the root of the hierarchy + # we just joined. In most cases the two hierarchies will be the same. + def lock_both_roots + parent_ids = [ + parent_id_was || self.id, + parent_id || self.id + ].compact + + roots = Gitlab::ObjectHierarchy + .new(Namespace.where(id: parent_ids)) + .base_and_ancestors + .reorder(nil) + .where(parent_id: nil) + + Namespace.lock.select(:id).where(id: roots).order(id: :asc).load + end + # Make sure we drop the STI `type = 'Group'` condition for better performance. # Logically equivalent so long as hierarchies remain homogeneous. def without_sti_condition @@ -82,29 +115,29 @@ module Namespaces end # Search this namespace's lineage. Bound inclusively by top node. - def lineage(top) - raise UnboundedSearch.new('Must bound search by a top') unless top + def lineage(top: nil, bottom: nil, hierarchy_order: nil) + raise UnboundedSearch, 'Must bound search by either top or bottom' unless top || bottom - without_sti_condition - .traversal_ids_contains(latest_traversal_ids(top)) - end + skope = without_sti_condition - # traversal_ids are a cached value. - # - # The traversal_ids value in a loaded object can become stale when compared - # to the database value. For example, if you load a hierarchy and then move - # a group, any previously loaded descendant objects will have out of date - # traversal_ids. - # - # To solve this problem, we never depend on the object's traversal_ids - # value. We always query the database first with a sub-select for the - # latest traversal_ids. - # - # Note that ActiveRecord will cache query results. You can avoid this by - # using `Model.uncached { ... }` - def latest_traversal_ids(namespace = self) - without_sti_condition.where('id = (?)', namespace) - .select('traversal_ids as latest_traversal_ids') + if top + skope = skope.traversal_ids_contains("{#{top.id}}") + end + + if bottom + skope = skope.where(id: bottom.traversal_ids[0..-1]) + end + + # The original `with_depth` attribute in ObjectHierarchy increments as you + # walk away from the "base" namespace. This direction changes depending on + # if you are walking up the ancestors or down the descendants. + if hierarchy_order + depth_sql = "ABS(#{traversal_ids.count} - array_length(traversal_ids, 1))" + skope = skope.select(skope.arel_table[Arel.star], "#{depth_sql} as depth") + .order(depth: hierarchy_order) + end + + skope end end end diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 9da454125eb..560ff861105 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -27,7 +27,7 @@ module Network @project .notes - .where('noteable_type = ?', 'Commit') + .where(noteable_type: 'Commit') .group('notes.commit_id') .select('notes.commit_id, count(notes.id) as note_count') .each do |item| diff --git a/app/models/note.rb b/app/models/note.rb index 3e560a09fbd..ae4a8859d4d 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -107,6 +107,7 @@ class Note < ApplicationRecord scope :fresh, -> { order_created_asc.with_order_id_asc } scope :updated_after, ->(time) { where('updated_at > ?', time) } scope :with_updated_at, ->(time) { where(updated_at: time) } + scope :with_suggestions, -> { joins(:suggestions) } scope :inc_author_project, -> { includes(:project, :author) } scope :inc_author, -> { includes(:author) } scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) } @@ -319,7 +320,7 @@ class Note < ApplicationRecord return commit if for_commit? super - rescue + rescue StandardError # Temp fix to prevent app crash # if note commit id doesn't exist nil @@ -495,7 +496,7 @@ class Note < ApplicationRecord noteable&.expire_note_etag_cache end - def touch(*args) + def touch(*args, **kwargs) # We're not using an explicit transaction here because this would in all # cases result in all future queries going to the primary, even if no writes # are performed. @@ -638,4 +639,4 @@ class Note < ApplicationRecord end end -Note.prepend_if_ee('EE::Note') +Note.prepend_mod_with('Note') diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 3d049336d44..4323f89865a 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -118,4 +118,4 @@ class NotificationSetting < ApplicationRecord end end -NotificationSetting.prepend_if_ee('EE::NotificationSetting') +NotificationSetting.prepend_mod_with('NotificationSetting') diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb index be3f719ddb3..537543a7ff0 100644 --- a/app/models/operations/feature_flag.rb +++ b/app/models/operations/feature_flag.rb @@ -97,7 +97,7 @@ module Operations issues = ::Issue .select('issues.*, operations_feature_flags_issues.id AS link_id') .joins(:feature_flag_issues) - .where('operations_feature_flags_issues.feature_flag_id = ?', id) + .where(operations_feature_flags_issues: { feature_flag_id: id }) .order('operations_feature_flags_issues.id ASC') .includes(preload) diff --git a/app/models/packages.rb b/app/models/packages.rb index e14c9290093..19490d23ce4 100644 --- a/app/models/packages.rb +++ b/app/models/packages.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true module Packages + DuplicatePackageError = Class.new(StandardError) + def self.table_name_prefix 'packages_' end diff --git a/app/models/packages/debian/group_distribution.rb b/app/models/packages/debian/group_distribution.rb index eea7acacc96..50c1ec9f163 100644 --- a/app/models/packages/debian/group_distribution.rb +++ b/app/models/packages/debian/group_distribution.rb @@ -6,4 +6,14 @@ class Packages::Debian::GroupDistribution < ApplicationRecord end include Packages::Debian::Distribution + + def packages + Packages::Package + .for_projects(group.all_projects.public_only) + .with_debian_codename(codename) + end + + def package_files + ::Packages::PackageFile.for_package_ids(packages.select(:id)) + end end diff --git a/app/models/packages/debian/project_distribution.rb b/app/models/packages/debian/project_distribution.rb index 22f1008b3b5..5ac60d789b3 100644 --- a/app/models/packages/debian/project_distribution.rb +++ b/app/models/packages/debian/project_distribution.rb @@ -5,8 +5,9 @@ class Packages::Debian::ProjectDistribution < ApplicationRecord :project end + include Packages::Debian::Distribution + has_many :publications, class_name: 'Packages::Debian::Publication', inverse_of: :distribution, foreign_key: :distribution_id has_many :packages, class_name: 'Packages::Package', through: :publications - - include Packages::Debian::Distribution + has_many :package_files, class_name: 'Packages::PackageFile', through: :packages end diff --git a/app/models/packages/go/module.rb b/app/models/packages/go/module.rb index b38b691ed6c..00d51c21881 100644 --- a/app/models/packages/go/module.rb +++ b/app/models/packages/go/module.rb @@ -18,8 +18,8 @@ module Packages end def version_by(ref: nil, commit: nil) - raise ArgumentError.new 'no filter specified' unless ref || commit - raise ArgumentError.new 'ref and commit are mutually exclusive' if ref && commit + raise ArgumentError, 'no filter specified' unless ref || commit + raise ArgumentError, 'ref and commit are mutually exclusive' if ref && commit if commit return version_by_sha(commit) if commit.is_a? String diff --git a/app/models/packages/go/module_version.rb b/app/models/packages/go/module_version.rb index fd575e6c96c..c442b2416f1 100644 --- a/app/models/packages/go/module_version.rb +++ b/app/models/packages/go/module_version.rb @@ -17,15 +17,15 @@ module Packages delegate :build, to: :@semver, allow_nil: true def initialize(mod, type, commit, name: nil, semver: nil, ref: nil) - raise ArgumentError.new("invalid type '#{type}'") unless VALID_TYPES.include? type - raise ArgumentError.new("mod is required") unless mod - raise ArgumentError.new("commit is required") unless commit + raise ArgumentError, "invalid type '#{type}'" unless VALID_TYPES.include? type + raise ArgumentError, "mod is required" unless mod + raise ArgumentError, "commit is required" unless commit if type == :ref - raise ArgumentError.new("ref is required") unless ref + raise ArgumentError, "ref is required" unless ref elsif type == :pseudo - raise ArgumentError.new("name is required") unless name - raise ArgumentError.new("semver is required") unless semver + raise ArgumentError, "name is required" unless name + raise ArgumentError, "semver is required" unless semver end @mod = mod diff --git a/app/models/packages/helm.rb b/app/models/packages/helm.rb new file mode 100644 index 00000000000..e021b997bf5 --- /dev/null +++ b/app/models/packages/helm.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Packages + module Helm + def self.table_name_prefix + 'packages_helm_' + end + end +end diff --git a/app/models/packages/helm/file_metadatum.rb b/app/models/packages/helm/file_metadatum.rb new file mode 100644 index 00000000000..1771003d1f9 --- /dev/null +++ b/app/models/packages/helm/file_metadatum.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Packages + module Helm + class FileMetadatum < ApplicationRecord + self.primary_key = :package_file_id + + belongs_to :package_file, inverse_of: :helm_file_metadatum + + validates :package_file, presence: true + validate :valid_helm_package_type + + validates :channel, + presence: true, + length: { maximum: 63 }, + format: { with: Gitlab::Regex.helm_channel_regex } + + validates :metadata, + json_schema: { filename: "helm_metadata" } + + private + + def valid_helm_package_type + return if package_file&.package&.helm? + + errors.add(:package_file, _('Package type must be Helm')) + end + end + end +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index e510432be8f..36edf646658 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -6,6 +6,7 @@ class Packages::Package < ApplicationRecord include Gitlab::Utils::StrongMemoize DISPLAYABLE_STATUSES = [:default, :error].freeze + INSTALLABLE_STATUSES = [:default].freeze belongs_to :project belongs_to :creator, class_name: 'User' @@ -47,8 +48,10 @@ class Packages::Package < ApplicationRecord validate :package_already_taken, if: :npm? validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic? + validates :name, format: { with: Gitlab::Regex.helm_package_regex }, if: :helm? validates :name, format: { with: Gitlab::Regex.npm_package_name_regex }, if: :npm? validates :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget? + validates :name, format: { with: Gitlab::Regex.terraform_module_package_name_regex }, if: :terraform_module? validates :name, format: { with: Gitlab::Regex.debian_package_name_regex }, if: :debian_package? validates :name, inclusion: { in: %w[incoming] }, if: :debian_incoming? validates :version, format: { with: Gitlab::Regex.nuget_version_regex }, if: :nuget? @@ -56,7 +59,8 @@ class Packages::Package < ApplicationRecord validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? } validates :version, format: { with: Gitlab::Regex.pypi_version_regex }, if: :pypi? validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :golang? - validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { composer_tag_version? || npm? } + validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :helm? + validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { composer_tag_version? || npm? || terraform_module? } validates :version, presence: true, @@ -70,10 +74,11 @@ class Packages::Package < ApplicationRecord enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7, golang: 8, debian: 9, - rubygems: 10 } + rubygems: 10, helm: 11, terraform_module: 12 } enum status: { default: 0, hidden: 1, processing: 2, error: 3 } + scope :for_projects, ->(project_ids) { where(project_id: project_ids) } scope :with_name, ->(name) { where(name: name) } scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) } scope :with_normalized_pypi_name, ->(name) { where("LOWER(regexp_replace(name, '[-_.]+', '-', 'g')) = ?", name.downcase) } @@ -81,8 +86,10 @@ class Packages::Package < ApplicationRecord scope :with_version, ->(version) { where(version: version) } scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) } scope :with_package_type, ->(package_type) { where(package_type: package_type) } + scope :without_package_type, ->(package_type) { where.not(package_type: package_type) } scope :with_status, ->(status) { where(status: status) } scope :displayable, -> { with_status(DISPLAYABLE_STATUSES) } + scope :installable, -> { with_status(INSTALLABLE_STATUSES) } scope :including_build_info, -> { includes(pipelines: :user) } scope :including_project_route, -> { includes(project: { namespace: :route }) } scope :including_tags, -> { includes(:tags) } @@ -110,25 +117,20 @@ class Packages::Package < ApplicationRecord scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } scope :has_version, -> { where.not(version: nil) } - scope :processed, -> do - where.not(package_type: :nuget).or( - where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) - ) - end scope :preload_files, -> { preload(:package_files) } scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) } scope :limit_recent, ->(limit) { order_created_desc.limit(limit) } scope :select_distinct_name, -> { select(:name).distinct } # Sorting - scope :order_created, -> { reorder('created_at ASC') } - scope :order_created_desc, -> { reorder('created_at DESC') } - scope :order_name, -> { reorder('name ASC') } - scope :order_name_desc, -> { reorder('name DESC') } - scope :order_version, -> { reorder('version ASC') } - scope :order_version_desc, -> { reorder('version DESC') } - scope :order_type, -> { reorder('package_type ASC') } - scope :order_type_desc, -> { reorder('package_type DESC') } + scope :order_created, -> { reorder(created_at: :asc) } + scope :order_created_desc, -> { reorder(created_at: :desc) } + scope :order_name, -> { reorder(name: :asc) } + scope :order_name_desc, -> { reorder(name: :desc) } + scope :order_version, -> { reorder(version: :asc) } + scope :order_version_desc, -> { reorder(version: :desc) } + scope :order_type, -> { reorder(package_type: :asc) } + scope :order_type_desc, -> { reorder(package_type: :desc) } scope :order_project_name, -> { joins(:project).reorder('projects.name ASC') } scope :order_project_name_desc, -> { joins(:project).reorder('projects.name DESC') } scope :order_project_path, -> { joins(:project).reorder('projects.path ASC, id ASC') } @@ -137,14 +139,6 @@ class Packages::Package < ApplicationRecord after_commit :update_composer_cache, on: :destroy, if: -> { composer? } - def self.for_projects(projects) - unless Feature.enabled?(:maven_packages_group_level_improvements, default_enabled: :yaml) - return none unless projects.any? - end - - where(project_id: projects) - end - def self.only_maven_packages_with_path(path, use_cte: false) if use_cte && Feature.enabled?(:maven_metadata_by_path_with_optimization_fence, default_enabled: :yaml) # This is an optimization fence which assumes that looking up the Metadatum record by path (globally) diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb index 23a7144e2bb..3d8641ca2fa 100644 --- a/app/models/packages/package_file.rb +++ b/app/models/packages/package_file.rb @@ -5,7 +5,8 @@ class Packages::PackageFile < ApplicationRecord delegate :project, :project_id, to: :package delegate :conan_file_type, to: :conan_file_metadatum - delegate :file_type, :architecture, :fields, to: :debian_file_metadatum, prefix: :debian + delegate :file_type, :component, :architecture, :fields, to: :debian_file_metadatum, prefix: :debian + delegate :channel, :metadata, to: :helm_file_metadatum, prefix: :helm belongs_to :package @@ -13,9 +14,11 @@ class Packages::PackageFile < ApplicationRecord has_many :package_file_build_infos, inverse_of: :package_file, class_name: 'Packages::PackageFileBuildInfo' has_many :pipelines, through: :package_file_build_infos has_one :debian_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Debian::FileMetadatum' + has_one :helm_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Helm::FileMetadatum' accepts_nested_attributes_for :conan_file_metadatum accepts_nested_attributes_for :debian_file_metadatum + accepts_nested_attributes_for :helm_file_metadatum validates :package, presence: true validates :file, presence: true @@ -24,6 +27,7 @@ class Packages::PackageFile < ApplicationRecord validates :file_name, uniqueness: { scope: :package }, if: -> { package&.pypi? } scope :recent, -> { order(id: :desc) } + scope :for_package_ids, ->(ids) { where(package_id: ids) } scope :with_file_name, ->(file_name) { where(file_name: file_name) } scope :with_file_name_like, ->(file_name) { where(arel_table[:file_name].matches(file_name)) } scope :with_files_stored_locally, -> { where(file_store: ::Packages::PackageFileUploader::Store::LOCAL) } @@ -41,7 +45,17 @@ class Packages::PackageFile < ApplicationRecord scope :with_debian_file_type, ->(file_type) do joins(:debian_file_metadatum) - .where(packages_debian_file_metadata: { debian_file_type: ::Packages::Debian::FileMetadatum.debian_file_types[file_type] }) + .where(packages_debian_file_metadata: { file_type: ::Packages::Debian::FileMetadatum.file_types[file_type] }) + end + + scope :with_debian_component_name, ->(component_name) do + joins(:debian_file_metadatum) + .where(packages_debian_file_metadata: { component: component_name }) + end + + scope :with_debian_architecture_name, ->(architecture_name) do + joins(:debian_file_metadatum) + .where(packages_debian_file_metadata: { architecture: architecture_name }) end scope :with_conan_package_reference, ->(conan_package_reference) do @@ -66,4 +80,4 @@ class Packages::PackageFile < ApplicationRecord end end -Packages::PackageFile.prepend_if_ee('EE::Packages::PackageFile') +Packages::PackageFile.prepend_mod_with('Packages::PackageFile') diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index 3285a1f7f4c..17131cd736d 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -50,8 +50,6 @@ module Pages def zip_source return unless deployment&.file - return if deployment.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project, default_enabled: :yaml) - global_id = ::Gitlab::GlobalId.build(deployment, id: deployment.id).to_s { @@ -64,17 +62,16 @@ module Pages } end + # TODO: remove support for legacy storage in 14.3 https://gitlab.com/gitlab-org/gitlab/-/issues/328712 + # we support this till 14.3 to allow people to still use legacy storage if something goes very wrong + # on self-hosted installations, and we'll need some time to fix it def legacy_source - raise LegacyStorageDisabledError unless Feature.enabled?(:pages_serve_from_legacy_storage, default_enabled: true) + return unless ::Settings.pages.local_store.enabled { type: 'file', path: File.join(project.full_path, 'public/') } - rescue LegacyStorageDisabledError => e - Gitlab::ErrorTracking.track_exception(e, project_id: project.id) - - nil end end end diff --git a/app/models/pages/virtual_domain.rb b/app/models/pages/virtual_domain.rb index 90cb8253b52..497f67993ae 100644 --- a/app/models/pages/virtual_domain.rb +++ b/app/models/pages/virtual_domain.rb @@ -21,9 +21,7 @@ module Pages project.pages_lookup_path(trim_prefix: trim_prefix, domain: domain) end - # TODO: remove in https://gitlab.com/gitlab-org/gitlab/-/issues/297524 - # source can only be nil if pages_serve_from_legacy_storage FF is disabled - # we can remove this filtering once we remove legacy storage + # TODO: remove in https://gitlab.com/gitlab-org/gitlab/-/issues/328715 paths = paths.select(&:source) paths.sort_by(&:prefix).reverse diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 4d60489e599..4668fc265a0 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -311,4 +311,4 @@ class PagesDomain < ApplicationRecord end end -PagesDomain.prepend_if_ee('::EE::PagesDomain') +PagesDomain.prepend_mod_with('PagesDomain') diff --git a/app/models/pages_domain_acme_order.rb b/app/models/pages_domain_acme_order.rb index 411456cc237..8427176fa72 100644 --- a/app/models/pages_domain_acme_order.rb +++ b/app/models/pages_domain_acme_order.rb @@ -14,7 +14,7 @@ class PagesDomainAcmeOrder < ApplicationRecord attr_encrypted :private_key, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: true diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index ad2f4525171..732ed0b7bb3 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -55,7 +55,7 @@ class PersonalAccessToken < ApplicationRecord begin Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) - rescue => ex + rescue StandardError => ex logger.warn "Failed to decrypt #{self.name} value stored in Redis for key ##{redis_key}: #{ex.class}" encrypted_token end @@ -110,4 +110,4 @@ class PersonalAccessToken < ApplicationRecord end end -PersonalAccessToken.prepend_if_ee('EE::PersonalAccessToken') +PersonalAccessToken.prepend_mod_with('PersonalAccessToken') diff --git a/app/models/plan.rb b/app/models/plan.rb index 6a7f32a5d5f..f3ef04315f8 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -39,4 +39,4 @@ class Plan < ApplicationRecord end end -Plan.prepend_if_ee('EE::Plan') +Plan.prepend_mod_with('Plan') diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index 94992adfd1e..78cddaa1302 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -115,4 +115,4 @@ class PoolRepository < ApplicationRecord end end -PoolRepository.prepend_if_ee('EE::PoolRepository') +PoolRepository.prepend_mod_with('PoolRepository') diff --git a/app/models/preloaders/labels_preloader.rb b/app/models/preloaders/labels_preloader.rb index 427f2869aac..bb3206f5399 100644 --- a/app/models/preloaders/labels_preloader.rb +++ b/app/models/preloaders/labels_preloader.rb @@ -31,4 +31,4 @@ module Preloaders end end -Preloaders::LabelsPreloader.prepend_if_ee('EE::Preloaders::LabelsPreloader') +Preloaders::LabelsPreloader.prepend_mod_with('Preloaders::LabelsPreloader') diff --git a/app/models/project.rb b/app/models/project.rb index f03e5293b58..9d572b7e2f8 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -19,6 +19,7 @@ class Project < ApplicationRecord include Presentable include HasRepository include HasWiki + include HasIntegrations include CanMoveRepositoryStorage include Routable include GroupDescendant @@ -33,7 +34,6 @@ class Project < ApplicationRecord include OptionallySearch include FromUnion include IgnorableColumns - include Integration include Repositories::CanHousekeepRepository include EachBatch include GitlabRoutingHelper @@ -104,16 +104,13 @@ class Project < ApplicationRecord after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? } - after_create :create_project_feature, unless: :project_feature + after_create -> { create_or_load_association(:project_feature) } - after_create :create_ci_cd_settings, - unless: :ci_cd_settings + after_create -> { create_or_load_association(:ci_cd_settings) } - after_create :create_container_expiration_policy, - unless: :container_expiration_policy + after_create -> { create_or_load_association(:container_expiration_policy) } - after_create :create_pages_metadatum, - unless: :pages_metadatum + after_create -> { create_or_load_association(:pages_metadatum) } after_create :set_timestamps_for_create after_update :update_forks_visibility_level @@ -131,7 +128,41 @@ class Project < ApplicationRecord after_initialize :use_hashed_storage after_create :check_repository_absence! - acts_as_ordered_taggable + acts_as_ordered_taggable_on :topics + # The 'tag_list' alias and the 'has_many' associations are required during the 'tags -> topics' migration + # TODO: eliminate 'tag_list', 'topic_taggings' and 'tags' in the further process of the migration + # https://gitlab.com/gitlab-org/gitlab/-/issues/331081 + alias_attribute :tag_list, :topic_list + has_many :topic_taggings, -> { includes(:tag).order("#{ActsAsTaggableOn::Tagging.table_name}.id") }, + as: :taggable, + class_name: 'ActsAsTaggableOn::Tagging', + after_add: :dirtify_tag_list, + after_remove: :dirtify_tag_list + has_many :topics, -> { order("#{ActsAsTaggableOn::Tagging.table_name}.id") }, + class_name: 'ActsAsTaggableOn::Tag', + through: :topic_taggings, + source: :tag + has_many :tags, -> { order("#{ActsAsTaggableOn::Tagging.table_name}.id") }, + class_name: 'ActsAsTaggableOn::Tag', + through: :topic_taggings, + source: :tag + + # Overwriting 'topic_list' and 'topic_list=' is necessary to ensure functionality during the background migration [1]. + # [1] https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61237 + # TODO: remove 'topic_list' and 'topic_list=' once the background migration is complete + # https://gitlab.com/gitlab-org/gitlab/-/issues/331081 + def topic_list + # Return both old topics (context 'tags') and new topics (context 'topics') + tag_list_on('tags') + tag_list_on('topics') + end + + def topic_list=(new_tags) + # Old topics with context 'tags' are added as new topics with context 'topics' + super(new_tags) + + # Remove old topics with context 'tags' + set_tag_list_on('tags', '') + end attr_accessor :old_path_with_namespace attr_accessor :template_name @@ -151,26 +182,26 @@ class Project < ApplicationRecord has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' has_many :boards - # Project services - has_one :campfire_service - has_one :datadog_service + # Project integrations + has_one :asana_service, class_name: 'Integrations::Asana' + has_one :assembla_service, class_name: 'Integrations::Assembla' + has_one :bamboo_service, class_name: 'Integrations::Bamboo' + has_one :campfire_service, class_name: 'Integrations::Campfire' + has_one :confluence_service, class_name: 'Integrations::Confluence' + has_one :datadog_service, class_name: 'Integrations::Datadog' + has_one :emails_on_push_service, class_name: 'Integrations::EmailsOnPush' has_one :discord_service has_one :drone_ci_service - has_one :emails_on_push_service has_one :ewm_service has_one :pipelines_email_service has_one :irker_service has_one :pivotaltracker_service - has_one :hipchat_service has_one :flowdock_service - has_one :assembla_service - has_one :asana_service has_one :mattermost_slash_commands_service has_one :mattermost_service has_one :slack_slash_commands_service has_one :slack_service has_one :buildkite_service - has_one :bamboo_service has_one :teamcity_service has_one :pushover_service has_one :jenkins_service @@ -179,7 +210,6 @@ class Project < ApplicationRecord has_one :youtrack_service has_one :custom_issue_tracker_service has_one :bugzilla_service - has_one :confluence_service has_one :external_wiki_service has_one :prometheus_service, inverse_of: :project has_one :mock_ci_service @@ -227,7 +257,7 @@ class Project < ApplicationRecord has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' has_many :issues has_many :labels, class_name: 'ProjectLabel' - has_many :services + has_many :integrations has_many :events has_many :milestones has_many :iterations @@ -338,7 +368,8 @@ class Project < ApplicationRecord has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :remote_mirrors, inverse_of: :project - has_many :cycle_analytics_stages, class_name: 'Analytics::CycleAnalytics::ProjectStage' + has_many :cycle_analytics_stages, class_name: 'Analytics::CycleAnalytics::ProjectStage', inverse_of: :project + has_many :value_streams, class_name: 'Analytics::CycleAnalytics::ProjectValueStream', inverse_of: :project has_many :external_pull_requests, inverse_of: :project @@ -371,6 +402,8 @@ class Project < ApplicationRecord has_one :operations_feature_flags_client, class_name: 'Operations::FeatureFlagsClient' has_many :operations_feature_flags_user_lists, class_name: 'Operations::FeatureFlags::UserList' + has_many :timelogs + accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature, update_only: true accepts_nested_attributes_for :project_setting, update_only: true @@ -528,7 +561,7 @@ class Project < ApplicationRecord scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct } scope :with_push, -> { joins(:events).merge(Event.pushed_action) } scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') } - scope :with_active_jira_services, -> { joins(:services).merge(::JiraService.active) } # rubocop:disable CodeReuse/ServiceClass + scope :with_active_jira_services, -> { joins(:integrations).merge(::JiraService.active) } # rubocop:disable CodeReuse/ServiceClass scope :with_jira_dvcs_cloud, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: true)) } scope :with_jira_dvcs_server, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: false)) } scope :inc_routes, -> { includes(:route, namespace: :route) } @@ -619,7 +652,7 @@ class Project < ApplicationRecord mount_uploader :bfg_object_map, AttachmentUploader def self.with_api_entity_associations - preload(:project_feature, :route, :tags, :group, namespace: [:route, :owner]) + preload(:project_feature, :route, :tags, :group, :timelogs, namespace: [:route, :owner]) end def self.with_web_entity_associations @@ -832,6 +865,10 @@ class Project < ApplicationRecord super end + def parent_loaded? + association(:namespace).loaded? + end + def project_setting super.presence || build_project_setting end @@ -1005,7 +1042,7 @@ class Project < ApplicationRecord end def latest_successful_build_for_ref!(job_name, ref = default_branch) - latest_successful_build_for_ref(job_name, ref) || raise(ActiveRecord::RecordNotFound.new("Couldn't find job #{job_name}")) + latest_successful_build_for_ref(job_name, ref) || raise(ActiveRecord::RecordNotFound, "Couldn't find job #{job_name}") end def latest_pipeline(ref = default_branch, sha = nil) @@ -1098,7 +1135,7 @@ class Project < ApplicationRecord else super end - rescue + rescue StandardError super end @@ -1342,7 +1379,7 @@ class Project < ApplicationRecord return unless has_external_issue_tracker? - @external_issue_tracker ||= services.external_issue_trackers.first + @external_issue_tracker ||= integrations.external_issue_trackers.first end def external_references_supported? @@ -1358,11 +1395,11 @@ class Project < ApplicationRecord return unless has_external_wiki? - @external_wiki ||= services.external_wikis.first + @external_wiki ||= integrations.external_wikis.first end def find_or_initialize_services - available_services_names = Service.available_services_names - disabled_services + available_services_names = Integration.available_services_names - disabled_services available_services_names.map do |service_name| find_or_initialize_service(service_name) @@ -1378,7 +1415,7 @@ class Project < ApplicationRecord def find_or_initialize_service(name) return if disabled_services.include?(name) - find_service(services, name) || build_from_instance_or_template(name) || build_service(name) + find_service(integrations, name) || build_from_instance_or_template(name) || build_service(name) end # rubocop: disable CodeReuse/ServiceClass @@ -1391,7 +1428,7 @@ class Project < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def ci_services - services.where(category: :ci) + integrations.where(category: :ci) end def ci_service @@ -1399,7 +1436,7 @@ class Project < ApplicationRecord end def monitoring_services - services.where(category: :monitoring) + integrations.where(category: :monitoring) end def monitoring_service @@ -1477,8 +1514,8 @@ class Project < ApplicationRecord def execute_services(data, hooks_scope = :push_hooks) # Call only service hooks that are active for this scope run_after_commit_or_now do - services.public_send(hooks_scope).each do |service| # rubocop:disable GitlabSecurity/PublicSend - service.async_execute(data) + integrations.public_send(hooks_scope).each do |integration| # rubocop:disable GitlabSecurity/PublicSend + integration.async_execute(data) end end end @@ -1488,7 +1525,7 @@ class Project < ApplicationRecord end def has_active_services?(hooks_scope = :push_hooks) - services.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend + integrations.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend end def feature_usage @@ -1560,7 +1597,7 @@ class Project < ApplicationRecord repository.after_create true - rescue => err + rescue StandardError => err Gitlab::ErrorTracking.track_exception(err, project: { id: id, full_path: full_path, disk_path: disk_path }) errors.add(:base, _('Failed to create repository')) false @@ -2417,7 +2454,7 @@ class Project < ApplicationRecord end def access_request_approvers_to_be_notified - members.maintainers.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) + members.maintainers.connected_to_user.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) end def pages_lookup_path(trim_prefix: nil, domain: nil) @@ -2529,12 +2566,14 @@ class Project < ApplicationRecord namespace.root_ancestor.all_projects .joins(:packages) .where.not(id: id) - .merge(Packages::Package.with_name(package_name)) + .merge(Packages::Package.default_scoped.with_name(package_name)) .exists? end - def default_branch_or_master - default_branch || 'master' + def default_branch_or_main + return default_branch if default_branch + + Gitlab::DefaultBranch.value(object: self) end def ci_config_path_or_default @@ -2569,6 +2608,16 @@ class Project < ApplicationRecord Feature.enabled?(:inherited_issuable_templates, self, default_enabled: :yaml) end + def activity_path + Gitlab::Routing.url_helpers.activity_project_path(self) + end + + def increment_statistic_value(statistic, delta) + return if pending_delete? + + ProjectStatistics.increment_statistic(self, statistic, delta) + end + private def set_container_registry_access_level @@ -2591,22 +2640,22 @@ class Project < ApplicationRecord def build_from_instance_or_template(name) instance = find_service(services_instances, name) - return Service.build_from_integration(instance, project_id: id) if instance + return Integration.build_from_integration(instance, project_id: id) if instance template = find_service(services_templates, name) - return Service.build_from_integration(template, project_id: id) if template + return Integration.build_from_integration(template, project_id: id) if template end def build_service(name) - "#{name}_service".classify.constantize.new(project_id: id) + Integration.service_name_to_model(name).new(project_id: id) end def services_templates - @services_templates ||= Service.for_template + @services_templates ||= Integration.for_template end def services_instances - @services_instances ||= Service.for_instance + @services_instances ||= Integration.for_instance end def closest_namespace_setting(name) @@ -2664,7 +2713,7 @@ class Project < ApplicationRecord def cross_namespace_reference?(from) case from when Project - namespace != from.namespace + namespace_id != from.namespace_id when Namespace namespace != from when User @@ -2743,11 +2792,11 @@ class Project < ApplicationRecord end def cache_has_external_wiki - update_column(:has_external_wiki, services.external_wikis.any?) if Gitlab::Database.read_write? + update_column(:has_external_wiki, integrations.external_wikis.any?) if Gitlab::Database.read_write? end def cache_has_external_issue_tracker - update_column(:has_external_issue_tracker, services.external_issue_trackers.any?) if Gitlab::Database.read_write? + update_column(:has_external_issue_tracker, integrations.external_issue_trackers.any?) if Gitlab::Database.read_write? end def active_runners_with_tags @@ -2759,4 +2808,4 @@ class Project < ApplicationRecord end end -Project.prepend_if_ee('EE::Project') +Project.prepend_mod_with('Project') diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 2c3f70654f8..1fed166e4d0 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -31,4 +31,4 @@ class ProjectAuthorization < ApplicationRecord end end -ProjectAuthorization.prepend_if_ee('::EE::ProjectAuthorization') +ProjectAuthorization.prepend_mod_with('ProjectAuthorization') diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index 31be0759cd0..c0c2ea42d46 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -33,4 +33,4 @@ class ProjectCiCdSetting < ApplicationRecord end end -ProjectCiCdSetting.prepend_if_ee('EE::ProjectCiCdSetting') +ProjectCiCdSetting.prepend_mod_with('ProjectCiCdSetting') diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 15f6bedfc2e..eb4ad327438 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -145,4 +145,4 @@ class ProjectFeature < ApplicationRecord end end -ProjectFeature.prepend_if_ee('EE::ProjectFeature') +ProjectFeature.prepend_mod_with('ProjectFeature') diff --git a/app/models/project_feature_usage.rb b/app/models/project_feature_usage.rb index 02051310af7..d993db860c3 100644 --- a/app/models/project_feature_usage.rb +++ b/app/models/project_feature_usage.rb @@ -45,4 +45,4 @@ class ProjectFeatureUsage < ApplicationRecord end end -ProjectFeatureUsage.prepend_if_ee('EE::ProjectFeatureUsage') +ProjectFeatureUsage.prepend_mod_with('ProjectFeatureUsage') diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index f065246e8af..d704f4c2c87 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -2,6 +2,7 @@ class ProjectGroupLink < ApplicationRecord include Expirable + include EachBatch belongs_to :project belongs_to :group @@ -49,4 +50,4 @@ class ProjectGroupLink < ApplicationRecord end end -ProjectGroupLink.prepend_if_ee('EE::ProjectGroupLink') +ProjectGroupLink.prepend_mod_with('ProjectGroupLink') diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb index 87ac6d38787..d374ee120d1 100644 --- a/app/models/project_import_data.rb +++ b/app/models/project_import_data.rb @@ -3,7 +3,7 @@ require 'carrierwave/orm/activerecord' class ProjectImportData < ApplicationRecord - prepend_if_ee('::EE::ProjectImportData') # rubocop: disable Cop/InjectEnterpriseEditionModule + prepend_mod_with('ProjectImportData') # rubocop: disable Cop/InjectEnterpriseEditionModule belongs_to :project, inverse_of: :import_data attr_encrypted :credentials, diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index 4bd3ffbea2f..633e669b5fc 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -105,4 +105,4 @@ class ProjectImportState < ApplicationRecord end end -ProjectImportState.prepend_if_ee('EE::ProjectImportState') +ProjectImportState.prepend_mod_with('ProjectImportState') diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb deleted file mode 100644 index f31bf931a41..00000000000 --- a/app/models/project_services/asana_service.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -require 'asana' - -class AsanaService < Service - include ActionView::Helpers::UrlHelper - - prop_accessor :api_key, :restrict_to_branch - validates :api_key, presence: true, if: :activated? - - def title - 'Asana' - end - - def description - s_('AsanaService|Add commit messages as comments to Asana tasks') - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer' - s_('Add commit messages as comments to Asana tasks. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def self.to_param - 'asana' - end - - def fields - [ - { - type: 'text', - name: 'api_key', - title: 'API key', - help: s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.'), - # Example Personal Access Token from Asana docs - placeholder: '0/68a9e79b868c6789e79a124c30b0', - required: true - }, - { - type: 'text', - name: 'restrict_to_branch', - title: 'Restrict to branch (optional)', - help: s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') - } - ] - end - - def self.supported_events - %w(push) - end - - def client - @_client ||= begin - Asana::Client.new do |c| - c.authentication :access_token, api_key - end - end - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - # check the branch restriction is poplulated and branch is not included - branch = Gitlab::Git.ref_name(data[:ref]) - branch_restriction = restrict_to_branch.to_s - if branch_restriction.present? && branch_restriction.index(branch).nil? - return - end - - user = data[:user_name] - project_name = project.full_name - - data[:commits].each do |commit| - push_msg = s_("AsanaService|%{user} pushed to branch %{branch} of %{project_name} ( %{commit_url} ):") % { user: user, branch: branch, project_name: project_name, commit_url: commit[:url] } - check_commit(commit[:message], push_msg) - end - end - - def check_commit(message, push_msg) - # matches either: - # - #1234 - # - https://app.asana.com/0/{project_gid}/{task_gid} - # optionally preceded with: - # - fix/ed/es/ing - # - close/s/d - # - closing - issue_finder = %r{(fix\w*|clos[ei]\w*+)?\W*(?:https://app\.asana\.com/\d+/\w+/(\w+)|#(\w+))}i - - message.scan(issue_finder).each do |tuple| - # tuple will be - # [ 'fix', 'id_from_url', 'id_from_pound' ] - taskid = tuple[2] || tuple[1] - - begin - task = Asana::Resources::Task.find_by_id(client, taskid) - task.add_comment(text: "#{push_msg} #{message}") - - if tuple[0] - task.update(completed: true) - end - rescue => e - log_error(e.message) - next - end - end - end -end diff --git a/app/models/project_services/assembla_service.rb b/app/models/project_services/assembla_service.rb deleted file mode 100644 index 8845fb99605..00000000000 --- a/app/models/project_services/assembla_service.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class AssemblaService < Service - prop_accessor :token, :subdomain - validates :token, presence: true, if: :activated? - - def title - 'Assembla' - end - - def description - _('Manage projects.') - end - - def self.to_param - 'assembla' - end - - def fields - [ - { type: 'text', name: 'token', placeholder: '', required: true }, - { type: 'text', name: 'subdomain', placeholder: '' } - ] - end - - def self.supported_events - %w(push) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - url = "https://atlas.assembla.com/spaces/#{subdomain}/github_tool?secret_key=#{token}" - Gitlab::HTTP.post(url, body: { payload: data }.to_json, headers: { 'Content-Type' => 'application/json' }) - end -end diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb deleted file mode 100644 index a892d1a4314..00000000000 --- a/app/models/project_services/bamboo_service.rb +++ /dev/null @@ -1,181 +0,0 @@ -# frozen_string_literal: true - -class BambooService < CiService - include ActionView::Helpers::UrlHelper - include ReactiveService - - prop_accessor :bamboo_url, :build_key, :username, :password - - validates :bamboo_url, presence: true, public_url: true, if: :activated? - validates :build_key, presence: true, if: :activated? - validates :username, - presence: true, - if: ->(service) { service.activated? && service.password } - validates :password, - presence: true, - if: ->(service) { service.activated? && service.username } - - attr_accessor :response - - after_save :compose_service_hook, if: :activated? - before_update :reset_password - - def compose_service_hook - hook = service_hook || build_service_hook - hook.save - end - - def reset_password - if bamboo_url_changed? && !password_touched? - self.password = nil - end - end - - def title - s_('BambooService|Atlassian Bamboo') - end - - def description - s_('BambooService|Use the Atlassian Bamboo CI/CD server with GitLab.') - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), target: '_blank', rel: 'noopener noreferrer' - s_('BambooService|Use Atlassian Bamboo to run CI/CD pipelines. You must set up automatic revision labeling and a repository trigger in Bamboo. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def self.to_param - 'bamboo' - end - - def fields - [ - { - type: 'text', - name: 'bamboo_url', - title: s_('BambooService|Bamboo URL'), - placeholder: s_('https://bamboo.example.com'), - help: s_('BambooService|Bamboo service root URL.'), - required: true - }, - { - type: 'text', - name: 'build_key', - placeholder: s_('KEY'), - help: s_('BambooService|Bamboo build plan key.'), - required: true - }, - { - type: 'text', - name: 'username', - help: s_('BambooService|The user with API access to the Bamboo server.') - }, - { - type: 'password', - name: 'password', - non_empty_password_title: s_('ProjectService|Enter new password'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current password') - } - ] - end - - def build_page(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:build_page] } - end - - def commit_status(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - get_path("updateAndBuild.action", { buildKey: build_key }) - end - - def calculate_reactive_cache(sha, ref) - response = try_get_path("rest/api/latest/result/byChangeset/#{sha}") - - { build_page: read_build_page(response), commit_status: read_commit_status(response) } - end - - private - - def get_build_result(response) - return if response&.code != 200 - - # May be nil if no result, a single result hash, or an array if multiple results for a given changeset. - result = response.dig('results', 'results', 'result') - - # In case of multiple results, arbitrarily assume the last one is the most relevant. - return result.last if result.is_a?(Array) - - result - end - - def read_build_page(response) - result = get_build_result(response) - key = - if result.blank? - # If actual build link can't be determined, send user to build summary page. - build_key - else - # If actual build link is available, go to build result page. - result.dig('planResultKey', 'key') - end - - build_url("browse/#{key}") - end - - def read_commit_status(response) - return :error unless response && (response.code == 200 || response.code == 404) - - result = get_build_result(response) - status = - if result.blank? - 'Pending' - else - result.dig('buildState') - end - - return :error unless status.present? - - if status.include?('Success') - 'success' - elsif status.include?('Failed') - 'failed' - elsif status.include?('Pending') - 'pending' - else - :error - end - end - - def try_get_path(path, query_params = {}) - params = build_get_params(query_params) - params[:extra_log_info] = { project_id: project_id } - - Gitlab::HTTP.try_get(build_url(path), params) - end - - def get_path(path, query_params = {}) - Gitlab::HTTP.get(build_url(path), build_get_params(query_params)) - end - - def build_url(path) - Gitlab::Utils.append_path(bamboo_url, path) - end - - def build_get_params(query_params) - params = { verify: false, query: query_params } - return params if username.blank? && password.blank? - - query_params[:os_authType] = 'basic' - params[:basic_auth] = basic_auth - params - end - - def basic_auth - { username: username, password: password } - end -end diff --git a/app/models/project_services/bugzilla_service.rb b/app/models/project_services/bugzilla_service.rb index 4332db3e961..d1c56d2a4d5 100644 --- a/app/models/project_services/bugzilla_service.rb +++ b/app/models/project_services/bugzilla_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class BugzillaService < IssueTrackerService + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? def title @@ -8,7 +10,12 @@ class BugzillaService < IssueTrackerService end def description - s_('IssueTracker|Bugzilla issue tracker') + s_("IssueTracker|Use Bugzilla as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bugzilla'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use Bugzilla as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end def self.to_param diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb index 53bb7b47b41..f2ea5066e37 100644 --- a/app/models/project_services/buildkite_service.rb +++ b/app/models/project_services/buildkite_service.rb @@ -68,7 +68,7 @@ class BuildkiteService < CiService end def description - 'Buildkite is a platform for running fast, secure, and scalable continuous integration pipelines on your own infrastructure' + 'Run CI/CD pipelines with Buildkite.' end def self.to_param diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb deleted file mode 100644 index f2295a95b60..00000000000 --- a/app/models/project_services/builds_email_service.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -# This class is to be removed with 9.1 -# We should also by then remove BuildsEmailService from database -class BuildsEmailService < Service - def self.to_param - 'builds_email' - end - - def self.supported_events - %w[] - end -end diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb deleted file mode 100644 index ad26e42a21b..00000000000 --- a/app/models/project_services/campfire_service.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -class CampfireService < Service - prop_accessor :token, :subdomain, :room - validates :token, presence: true, if: :activated? - - def title - 'Campfire' - end - - def description - 'Simple web-based real-time group chat' - end - - def self.to_param - 'campfire' - end - - def fields - [ - { type: 'text', name: 'token', placeholder: '', required: true }, - { type: 'text', name: 'subdomain', placeholder: '' }, - { type: 'text', name: 'room', placeholder: '' } - ] - end - - def self.supported_events - %w(push) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - message = build_message(data) - speak(self.room, message, auth) - end - - private - - def base_uri - @base_uri ||= "https://#{subdomain}.campfirenow.com" - end - - def auth - # use a dummy password, as explained in the Campfire API doc: - # https://github.com/basecamp/campfire-api#authentication - @auth ||= { - basic_auth: { - username: token, - password: 'X' - } - } - end - - # Post a message into a room, returns the message Hash in case of success. - # Returns nil otherwise. - # https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message - def speak(room_name, message, auth) - room = rooms(auth).find { |r| r["name"] == room_name } - return unless room - - path = "/room/#{room["id"]}/speak.json" - body = { - body: { - message: { - type: 'TextMessage', - body: message - } - } - } - res = Gitlab::HTTP.post(path, base_uri: base_uri, **auth.merge(body)) - res.code == 201 ? res : nil - end - - # Returns a list of rooms, or []. - # https://github.com/basecamp/campfire-api/blob/master/sections/rooms.md#get-rooms - def rooms(auth) - res = Gitlab::HTTP.get("/rooms.json", base_uri: base_uri, **auth) - res.code == 200 ? res["rooms"] : [] - end - - def build_message(push) - ref = Gitlab::Git.ref_name(push[:ref]) - before = push[:before] - after = push[:after] - - message = [] - message << "[#{project.full_name}] " - message << "#{push[:user_name]} " - - if Gitlab::Git.blank_ref?(before) - message << "pushed new branch #{ref} \n" - elsif Gitlab::Git.blank_ref?(after) - message << "removed branch #{ref} \n" - else - message << "pushed #{push[:total_commits_count]} commits to #{ref}. " - message << "#{project.web_url}/compare/#{before}...#{after}" - end - - message.join - end -end diff --git a/app/models/project_services/chat_message/alert_message.rb b/app/models/project_services/chat_message/alert_message.rb deleted file mode 100644 index c8913775843..00000000000 --- a/app/models/project_services/chat_message/alert_message.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class AlertMessage < BaseMessage - attr_reader :title - attr_reader :alert_url - attr_reader :severity - attr_reader :events - attr_reader :status - attr_reader :started_at - - def initialize(params) - @project_name = params[:project_name] || params.dig(:project, :path_with_namespace) - @project_url = params.dig(:project, :web_url) || params[:project_url] - @title = params.dig(:object_attributes, :title) - @alert_url = params.dig(:object_attributes, :url) - @severity = params.dig(:object_attributes, :severity) - @events = params.dig(:object_attributes, :events) - @status = params.dig(:object_attributes, :status) - @started_at = params.dig(:object_attributes, :started_at) - end - - def attachments - [{ - title: title, - title_link: alert_url, - color: attachment_color, - fields: attachment_fields - }] - end - - def message - "Alert firing in #{project_name}" - end - - private - - def attachment_color - "#C95823" - end - - def attachment_fields - [ - { - title: "Severity", - value: severity.to_s.humanize, - short: true - }, - { - title: "Events", - value: events, - short: true - }, - { - title: "Status", - value: status.to_s.humanize, - short: true - }, - { - title: "Start time", - value: format_time(started_at), - short: true - } - ] - end - - # This formats time into the following format - # April 23rd, 2020 1:06AM UTC - def format_time(time) - time = Time.zone.parse(time.to_s) - time.strftime("%B #{time.day.ordinalize}, %Y %l:%M%p %Z") - end - end -end diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb deleted file mode 100644 index bdd77a919e3..00000000000 --- a/app/models/project_services/chat_message/base_message.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class BaseMessage - RELATIVE_LINK_REGEX = /!\[[^\]]*\]\((\/uploads\/[^\)]*)\)/.freeze - - attr_reader :markdown - attr_reader :user_full_name - attr_reader :user_name - attr_reader :user_avatar - attr_reader :project_name - attr_reader :project_url - - def initialize(params) - @markdown = params[:markdown] || false - @project_name = params[:project_name] || params.dig(:project, :path_with_namespace) - @project_url = params.dig(:project, :web_url) || params[:project_url] - @user_full_name = params.dig(:user, :name) || params[:user_full_name] - @user_name = params.dig(:user, :username) || params[:user_name] - @user_avatar = params.dig(:user, :avatar_url) || params[:user_avatar] - end - - def user_combined_name - if user_full_name.present? - "#{user_full_name} (#{user_name})" - else - user_name - end - end - - def summary - return message if markdown - - format(message) - end - - def pretext - summary - end - - def fallback - format(message) - end - - def attachments - raise NotImplementedError - end - - def activity - raise NotImplementedError - end - - private - - def message - raise NotImplementedError - end - - def format(string) - Slack::Messenger::Util::LinkFormatter.format(format_relative_links(string)) - end - - def format_relative_links(string) - string.gsub(RELATIVE_LINK_REGEX, "#{project_url}\\1") - end - - def attachment_color - '#345' - end - - def link(text, url) - "[#{text}](#{url})" - end - - def pretty_duration(seconds) - parse_string = - if duration < 1.hour - '%M:%S' - else - '%H:%M:%S' - end - - Time.at(seconds).utc.strftime(parse_string) - end - end -end diff --git a/app/models/project_services/chat_message/deployment_message.rb b/app/models/project_services/chat_message/deployment_message.rb deleted file mode 100644 index 5deb757e60f..00000000000 --- a/app/models/project_services/chat_message/deployment_message.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class DeploymentMessage < BaseMessage - attr_reader :commit_title - attr_reader :commit_url - attr_reader :deployable_id - attr_reader :deployable_url - attr_reader :environment - attr_reader :short_sha - attr_reader :status - attr_reader :user_url - - def initialize(data) - super - - @commit_title = data[:commit_title] - @commit_url = data[:commit_url] - @deployable_id = data[:deployable_id] - @deployable_url = data[:deployable_url] - @environment = data[:environment] - @short_sha = data[:short_sha] - @status = data[:status] - @user_url = data[:user_url] - end - - def attachments - [{ - text: "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{commit_title}", - color: color - }] - end - - def activity - {} - end - - private - - def message - if running? - "Starting deploy to #{environment}" - else - "Deploy to #{environment} #{humanized_status}" - end - end - - def color - case status - when 'success' - 'good' - when 'canceled' - 'warning' - when 'failed' - 'danger' - else - '#334455' - end - end - - def project_link - link(project_name, project_url) - end - - def deployment_link - link("##{deployable_id}", deployable_url) - end - - def user_link - link(user_combined_name, user_url) - end - - def commit_link - link(short_sha, commit_url) - end - - def humanized_status - status == 'success' ? 'succeeded' : status - end - - def running? - status == 'running' - end - end -end diff --git a/app/models/project_services/chat_message/issue_message.rb b/app/models/project_services/chat_message/issue_message.rb deleted file mode 100644 index c8e90b66bae..00000000000 --- a/app/models/project_services/chat_message/issue_message.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class IssueMessage < BaseMessage - attr_reader :title - attr_reader :issue_iid - attr_reader :issue_url - attr_reader :action - attr_reader :state - attr_reader :description - - def initialize(params) - super - - obj_attr = params[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - @title = obj_attr[:title] - @issue_iid = obj_attr[:iid] - @issue_url = obj_attr[:url] - @action = obj_attr[:action] - @state = obj_attr[:state] - @description = obj_attr[:description] || '' - end - - def attachments - return [] unless opened_issue? - return description if markdown - - description_message - end - - def activity - { - title: "Issue #{state} by #{user_combined_name}", - subtitle: "in #{project_link}", - text: issue_link, - image: user_avatar - } - end - - private - - def message - "[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}" - end - - def opened_issue? - action == 'open' - end - - def description_message - [{ - title: issue_title, - title_link: issue_url, - text: format(description), - color: '#C95823' - }] - end - - def project_link - link(project_name, project_url) - end - - def issue_link - link(issue_title, issue_url) - end - - def issue_title - "#{Issue.reference_prefix}#{issue_iid} #{title}" - end - end -end diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb deleted file mode 100644 index e45bb9b8ce1..00000000000 --- a/app/models/project_services/chat_message/merge_message.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class MergeMessage < BaseMessage - attr_reader :merge_request_iid - attr_reader :source_branch - attr_reader :target_branch - attr_reader :action - attr_reader :state - attr_reader :title - - def initialize(params) - super - - obj_attr = params[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - @merge_request_iid = obj_attr[:iid] - @source_branch = obj_attr[:source_branch] - @target_branch = obj_attr[:target_branch] - @action = obj_attr[:action] - @state = obj_attr[:state] - @title = format_title(obj_attr[:title]) - end - - def attachments - [] - end - - def activity - { - title: "Merge request #{state_or_action_text} by #{user_combined_name}", - subtitle: "in #{project_link}", - text: merge_request_link, - image: user_avatar - } - end - - private - - def format_title(title) - '*' + title.lines.first.chomp + '*' - end - - def message - merge_request_message - end - - def project_link - link(project_name, project_url) - end - - def merge_request_message - "#{user_combined_name} #{state_or_action_text} merge request #{merge_request_link} in #{project_link}" - end - - def merge_request_link - link(merge_request_title, merge_request_url) - end - - def merge_request_title - "#{MergeRequest.reference_prefix}#{merge_request_iid} #{title}" - end - - def merge_request_url - "#{project_url}/-/merge_requests/#{merge_request_iid}" - end - - def state_or_action_text - case action - when 'approved', 'unapproved' - action - when 'approval' - 'added their approval to' - when 'unapproval' - 'removed their approval from' - else - state - end - end - end -end diff --git a/app/models/project_services/chat_message/note_message.rb b/app/models/project_services/chat_message/note_message.rb deleted file mode 100644 index 741474fb27b..00000000000 --- a/app/models/project_services/chat_message/note_message.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class NoteMessage < BaseMessage - attr_reader :note - attr_reader :note_url - attr_reader :title - attr_reader :target - - def initialize(params) - super - - params = HashWithIndifferentAccess.new(params) - obj_attr = params[:object_attributes] - @note = obj_attr[:note] - @note_url = obj_attr[:url] - @target, @title = case obj_attr[:noteable_type] - when "Commit" - create_commit_note(params[:commit]) - when "Issue" - create_issue_note(params[:issue]) - when "MergeRequest" - create_merge_note(params[:merge_request]) - when "Snippet" - create_snippet_note(params[:snippet]) - end - end - - def attachments - return note if markdown - - description_message - end - - def activity - { - title: "#{user_combined_name} #{link('commented on ' + target, note_url)}", - subtitle: "in #{project_link}", - text: formatted_title, - image: user_avatar - } - end - - private - - def message - "#{user_combined_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*" - end - - def format_title(title) - title.lines.first.chomp - end - - def formatted_title - format_title(title) - end - - def create_issue_note(issue) - ["issue #{Issue.reference_prefix}#{issue[:iid]}", issue[:title]] - end - - def create_commit_note(commit) - commit_sha = Commit.truncate_sha(commit[:id]) - - ["commit #{commit_sha}", commit[:message]] - end - - def create_merge_note(merge_request) - ["merge request #{MergeRequest.reference_prefix}#{merge_request[:iid]}", merge_request[:title]] - end - - def create_snippet_note(snippet) - ["snippet #{Snippet.reference_prefix}#{snippet[:id]}", snippet[:title]] - end - - def description_message - [{ text: format(note), color: attachment_color }] - end - - def project_link - link(project_name, project_url) - end - end -end diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb deleted file mode 100644 index f4c6938fa78..00000000000 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ /dev/null @@ -1,265 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class PipelineMessage < BaseMessage - MAX_VISIBLE_JOBS = 10 - - attr_reader :user - attr_reader :ref_type - attr_reader :ref - attr_reader :status - attr_reader :detailed_status - attr_reader :duration - attr_reader :finished_at - attr_reader :pipeline_id - attr_reader :failed_stages - attr_reader :failed_jobs - - attr_reader :project - attr_reader :commit - attr_reader :committer - attr_reader :pipeline - - def initialize(data) - super - - @user = data[:user] - @user_name = data.dig(:user, :username) || 'API' - - pipeline_attributes = data[:object_attributes] - @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' - @ref = pipeline_attributes[:ref] - @status = pipeline_attributes[:status] - @detailed_status = pipeline_attributes[:detailed_status] - @duration = pipeline_attributes[:duration].to_i - @finished_at = pipeline_attributes[:finished_at] ? Time.parse(pipeline_attributes[:finished_at]).to_i : nil - @pipeline_id = pipeline_attributes[:id] - - # Get list of jobs that have actually failed (after exhausting all retries) - @failed_jobs = actually_failed_jobs(Array(data[:builds])) - @failed_stages = @failed_jobs.map { |j| j[:stage] }.uniq - - @project = Project.find(data[:project][:id]) - @commit = project.commit_by(oid: data[:commit][:id]) - @committer = commit.committer - @pipeline = Ci::Pipeline.find(pipeline_id) - end - - def pretext - '' - end - - def attachments - return message if markdown - - [{ - fallback: format(message), - color: attachment_color, - author_name: user_combined_name, - author_icon: user_avatar, - author_link: author_url, - title: s_("ChatMessage|Pipeline #%{pipeline_id} %{humanized_status} in %{duration}") % - { - pipeline_id: pipeline_id, - humanized_status: humanized_status, - duration: pretty_duration(duration) - }, - title_link: pipeline_url, - fields: attachments_fields, - footer: project.name, - footer_icon: project.avatar_url(only_path: false), - ts: finished_at - }] - end - - def activity - { - title: s_("ChatMessage|Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status}") % - { - pipeline_link: pipeline_link, - ref_type: ref_type, - ref_link: ref_link, - user_combined_name: user_combined_name, - humanized_status: humanized_status - }, - subtitle: s_("ChatMessage|in %{project_link}") % { project_link: project_link }, - text: s_("ChatMessage|in %{duration}") % { duration: pretty_duration(duration) }, - image: user_avatar || '' - } - end - - private - - def actually_failed_jobs(builds) - succeeded_job_names = builds.map { |b| b[:name] if b[:status] == 'success' }.compact.uniq - - failed_jobs = builds.select do |build| - # Select jobs which doesn't have a successful retry - build[:status] == 'failed' && !succeeded_job_names.include?(build[:name]) - end - - failed_jobs.uniq { |job| job[:name] }.reverse - end - - def failed_stages_field - { - title: s_("ChatMessage|Failed stage").pluralize(failed_stages.length), - value: Slack::Messenger::Util::LinkFormatter.format(failed_stages_links), - short: true - } - end - - def failed_jobs_field - { - title: s_("ChatMessage|Failed job").pluralize(failed_jobs.length), - value: Slack::Messenger::Util::LinkFormatter.format(failed_jobs_links), - short: true - } - end - - def yaml_error_field - { - title: s_("ChatMessage|Invalid CI config YAML file"), - value: pipeline.yaml_errors, - short: false - } - end - - def attachments_fields - fields = [ - { - title: ref_type == "tag" ? s_("ChatMessage|Tag") : s_("ChatMessage|Branch"), - value: Slack::Messenger::Util::LinkFormatter.format(ref_link), - short: true - }, - { - title: s_("ChatMessage|Commit"), - value: Slack::Messenger::Util::LinkFormatter.format(commit_link), - short: true - } - ] - - fields << failed_stages_field if failed_stages.any? - fields << failed_jobs_field if failed_jobs.any? - fields << yaml_error_field if pipeline.has_yaml_errors? - - fields - end - - def message - s_("ChatMessage|%{project_link}: Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status} in %{duration}") % - { - project_link: project_link, - pipeline_link: pipeline_link, - ref_type: ref_type, - ref_link: ref_link, - user_combined_name: user_combined_name, - humanized_status: humanized_status, - duration: pretty_duration(duration) - } - end - - def humanized_status - case status - when 'success' - detailed_status == "passed with warnings" ? s_("ChatMessage|has passed with warnings") : s_("ChatMessage|has passed") - when 'failed' - s_("ChatMessage|has failed") - else - status - end - end - - def attachment_color - case status - when 'success' - detailed_status == 'passed with warnings' ? 'warning' : 'good' - else - 'danger' - end - end - - def ref_url - if ref_type == 'tag' - "#{project_url}/-/tags/#{ref}" - else - "#{project_url}/-/commits/#{ref}" - end - end - - def ref_link - "[#{ref}](#{ref_url})" - end - - def project_url - project.web_url - end - - def project_link - "[#{project.name}](#{project_url})" - end - - def pipeline_failed_jobs_url - "#{project_url}/-/pipelines/#{pipeline_id}/failures" - end - - def pipeline_url - if failed_jobs.any? - pipeline_failed_jobs_url - else - "#{project_url}/-/pipelines/#{pipeline_id}" - end - end - - def pipeline_link - "[##{pipeline_id}](#{pipeline_url})" - end - - def job_url(job) - "#{project_url}/-/jobs/#{job[:id]}" - end - - def job_link(job) - "[#{job[:name]}](#{job_url(job)})" - end - - def failed_jobs_links - failed = failed_jobs.slice(0, MAX_VISIBLE_JOBS) - truncated = failed_jobs.slice(MAX_VISIBLE_JOBS, failed_jobs.size) - - failed_links = failed.map { |job| job_link(job) } - - unless truncated.blank? - failed_links << s_("ChatMessage|and [%{count} more](%{pipeline_failed_jobs_url})") % { - count: truncated.size, - pipeline_failed_jobs_url: pipeline_failed_jobs_url - } - end - - failed_links.join(I18n.translate(:'support.array.words_connector')) - end - - def stage_link(stage) - # All stages link to the pipeline page - "[#{stage}](#{pipeline_url})" - end - - def failed_stages_links - failed_stages.map { |s| stage_link(s) }.join(I18n.translate(:'support.array.words_connector')) - end - - def commit_url - Gitlab::UrlBuilder.build(commit) - end - - def commit_link - "[#{commit.title}](#{commit_url})" - end - - def author_url - return unless user && committer - - Gitlab::UrlBuilder.build(committer) - end - end -end diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb deleted file mode 100644 index c8e70a69c88..00000000000 --- a/app/models/project_services/chat_message/push_message.rb +++ /dev/null @@ -1,118 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class PushMessage < BaseMessage - attr_reader :after - attr_reader :before - attr_reader :commits - attr_reader :ref - attr_reader :ref_type - - def initialize(params) - super - - @after = params[:after] - @before = params[:before] - @commits = params.fetch(:commits, []) - @ref_type = Gitlab::Git.tag_ref?(params[:ref]) ? 'tag' : 'branch' - @ref = Gitlab::Git.ref_name(params[:ref]) - end - - def attachments - return [] if new_branch? || removed_branch? - return commit_messages if markdown - - commit_message_attachments - end - - def activity - { - title: humanized_action(short: true), - subtitle: "in #{project_link}", - text: compare_link, - image: user_avatar - } - end - - private - - def humanized_action(short: false) - action, ref_link, target_link = compose_action_details - text = [user_combined_name, action, ref_type, ref_link] - text << target_link unless short - text.join(' ') - end - - def message - humanized_action - end - - def format(string) - Slack::Messenger::Util::LinkFormatter.format(string) - end - - def commit_messages - commits.map { |commit| compose_commit_message(commit) }.join("\n\n") - end - - def commit_message_attachments - [{ text: format(commit_messages), color: attachment_color }] - end - - def compose_commit_message(commit) - author = commit[:author][:name] - id = Commit.truncate_sha(commit[:id]) - title = commit[:title] - - url = commit[:url] - - "[#{id}](#{url}): #{title} - #{author}" - end - - def new_branch? - Gitlab::Git.blank_ref?(before) - end - - def removed_branch? - Gitlab::Git.blank_ref?(after) - end - - def ref_url - if ref_type == 'tag' - "#{project_url}/-/tags/#{ref}" - else - "#{project_url}/commits/#{ref}" - end - end - - def compare_url - "#{project_url}/compare/#{before}...#{after}" - end - - def ref_link - "[#{ref}](#{ref_url})" - end - - def project_link - "[#{project_name}](#{project_url})" - end - - def compare_link - "[Compare changes](#{compare_url})" - end - - def compose_action_details - if new_branch? - ['pushed new', ref_link, "to #{project_link}"] - elsif removed_branch? - ['removed', ref, "from #{project_link}"] - else - ['pushed to', ref_link, "of #{project_link} (#{compare_link})"] - end - end - - def attachment_color - '#345' - end - end -end diff --git a/app/models/project_services/chat_message/wiki_page_message.rb b/app/models/project_services/chat_message/wiki_page_message.rb deleted file mode 100644 index ebe7abb379f..00000000000 --- a/app/models/project_services/chat_message/wiki_page_message.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class WikiPageMessage < BaseMessage - attr_reader :title - attr_reader :wiki_page_url - attr_reader :action - attr_reader :description - - def initialize(params) - super - - obj_attr = params[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - @title = obj_attr[:title] - @wiki_page_url = obj_attr[:url] - @description = obj_attr[:message] - - @action = - case obj_attr[:action] - when "create" - "created" - when "update" - "edited" - end - end - - def attachments - return description if markdown - - description_message - end - - def activity - { - title: "#{user_combined_name} #{action} #{wiki_page_link}", - subtitle: "in #{project_link}", - text: title, - image: user_avatar - } - end - - private - - def message - "#{user_combined_name} #{action} #{wiki_page_link} in #{project_link}: *#{title}*" - end - - def description_message - [{ text: format(@description), color: attachment_color }] - end - - def project_link - "[#{project_name}](#{project_url})" - end - - def wiki_page_link - "[wiki page](#{wiki_page_url})" - end - end -end diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index 4a99842b4d5..2f841bf903e 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -2,7 +2,7 @@ # Base class for Chat notifications services # This class is not meant to be used directly, but only to inherit from. -class ChatNotificationService < Service +class ChatNotificationService < Integration include ChatMessage include NotificationBranchSelection @@ -15,9 +15,14 @@ class ChatNotificationService < Service EVENT_CHANNEL = proc { |event| "#{event}_channel" } + LABEL_NOTIFICATION_BEHAVIOURS = [ + MATCH_ANY_LABEL = 'match_any', + MATCH_ALL_LABELS = 'match_all' + ].freeze + default_value_for :category, 'chat' - prop_accessor :webhook, :username, :channel, :branches_to_be_notified, :labels_to_be_notified + prop_accessor :webhook, :username, :channel, :branches_to_be_notified, :labels_to_be_notified, :labels_to_be_notified_behavior # Custom serialized properties initialization prop_accessor(*SUPPORTED_EVENTS.map { |event| EVENT_CHANNEL[event] }) @@ -25,12 +30,14 @@ class ChatNotificationService < Service boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch validates :webhook, presence: true, public_url: true, if: :activated? + validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true def initialize_properties if properties.nil? self.properties = {} self.notify_only_broken_pipelines = true self.branches_to_be_notified = "default" + self.labels_to_be_notified_behavior = MATCH_ANY_LABEL elsif !self.notify_only_default_branch.nil? # In older versions, there was only a boolean property named # `notify_only_default_branch`. Now we have a string property named @@ -65,7 +72,20 @@ class ChatNotificationService < Service { type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze, { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'Do not send notifications for successful pipelines.' }.freeze, { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze, - { type: 'text', name: 'labels_to_be_notified', placeholder: '~backend,~frontend', help: 'Send notifications for issue, merge request, and comment events with the listed labels only. Leave blank to receive notifications for all events.' }.freeze + { + type: 'text', + name: 'labels_to_be_notified', + placeholder: '~backend,~frontend', + help: 'Send notifications for issue, merge request, and comment events with the listed labels only. Leave blank to receive notifications for all events.' + }.freeze, + { + type: 'select', + name: 'labels_to_be_notified_behavior', + choices: [ + ['Match any of the labels', MATCH_ANY_LABEL], + ['Match all of the labels', MATCH_ALL_LABELS] + ] + }.freeze ].freeze end @@ -136,11 +156,17 @@ class ChatNotificationService < Service def notify_label?(data) return true unless SUPPORTED_EVENTS_FOR_LABEL_FILTER.include?(data[:object_kind]) && labels_to_be_notified.present? - issue_labels = data.dig(:issue, :labels) || [] - merge_request_labels = data.dig(:merge_request, :labels) || [] - label_titles = (issue_labels + merge_request_labels).pluck(:title) + labels = data.dig(:issue, :labels) || data.dig(:merge_request, :labels) + + return false if labels.nil? - (labels_to_be_notified_list & label_titles).any? + matching_labels = labels_to_be_notified_list & labels.pluck(:title) + + if labels_to_be_notified_behavior == MATCH_ALL_LABELS + labels_to_be_notified_list.difference(matching_labels).empty? + else + matching_labels.any? + end end def user_id_from_hook_data(data) @@ -159,19 +185,19 @@ class ChatNotificationService < Service def get_message(object_kind, data) case object_kind when "push", "tag_push" - ChatMessage::PushMessage.new(data) if notify_for_ref?(data) + Integrations::ChatMessage::PushMessage.new(data) if notify_for_ref?(data) when "issue" - ChatMessage::IssueMessage.new(data) unless update?(data) + Integrations::ChatMessage::IssueMessage.new(data) unless update?(data) when "merge_request" - ChatMessage::MergeMessage.new(data) unless update?(data) + Integrations::ChatMessage::MergeMessage.new(data) unless update?(data) when "note" - ChatMessage::NoteMessage.new(data) + Integrations::ChatMessage::NoteMessage.new(data) when "pipeline" - ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) + Integrations::ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) when "wiki_page" - ChatMessage::WikiPageMessage.new(data) + Integrations::ChatMessage::WikiPageMessage.new(data) when "deployment" - ChatMessage::DeploymentMessage.new(data) + Integrations::ChatMessage::DeploymentMessage.new(data) end end diff --git a/app/models/project_services/ci_service.rb b/app/models/project_services/ci_service.rb index 29edb9ec16f..0733da761d5 100644 --- a/app/models/project_services/ci_service.rb +++ b/app/models/project_services/ci_service.rb @@ -3,7 +3,7 @@ # Base class for CI services # List methods you need to implement to get your CI service # working with GitLab merge requests -class CiService < Service +class CiService < Integration default_value_for :category, 'ci' def valid_token?(token) diff --git a/app/models/project_services/confluence_service.rb b/app/models/project_services/confluence_service.rb deleted file mode 100644 index 8a6f4de540c..00000000000 --- a/app/models/project_services/confluence_service.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -class ConfluenceService < Service - include ActionView::Helpers::UrlHelper - - VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze - VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze - VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze - - prop_accessor :confluence_url - - validates :confluence_url, presence: true, if: :activated? - validate :validate_confluence_url_is_cloud, if: :activated? - - after_commit :cache_project_has_confluence - - def self.to_param - 'confluence' - end - - def self.supported_events - %w() - end - - def title - s_('ConfluenceService|Confluence Workspace') - end - - def description - s_('ConfluenceService|Connect a Confluence Cloud Workspace to GitLab') - end - - def help - return unless project&.wiki_enabled? - - if activated? - wiki_url = project.wiki.web_url - - s_( - 'ConfluenceService|Your GitLab Wiki can be accessed here: %{wiki_link}. To re-enable your GitLab Wiki, disable this integration' % - { wiki_link: link_to(wiki_url, wiki_url) } - ).html_safe - else - s_('ConfluenceService|Enabling the Confluence Workspace will disable the default GitLab Wiki. Your GitLab Wiki data will be saved and you can always re-enable it later by turning off this integration').html_safe - end - end - - def fields - [ - { - type: 'text', - name: 'confluence_url', - title: 'Confluence Cloud Workspace URL', - placeholder: s_('ConfluenceService|The URL of the Confluence Workspace'), - required: true - } - ] - end - - def can_test? - false - end - - private - - def validate_confluence_url_is_cloud - unless confluence_uri_valid? - errors.add(:confluence_url, 'URL must be to a Confluence Cloud Workspace hosted on atlassian.net') - end - end - - def confluence_uri_valid? - return false unless confluence_url - - uri = URI.parse(confluence_url) - - (uri.scheme&.match(VALID_SCHEME_MATCH) && - uri.host&.match(VALID_HOST_MATCH) && - uri.path&.match(VALID_PATH_MATCH)).present? - - rescue URI::InvalidURIError - false - end - - def cache_project_has_confluence - return unless project && !project.destroyed? - - project.project_setting.save! unless project.project_setting.persisted? - project.project_setting.update_column(:has_confluence, active?) - end -end diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb index aab8661ec55..6f99d104904 100644 --- a/app/models/project_services/custom_issue_tracker_service.rb +++ b/app/models/project_services/custom_issue_tracker_service.rb @@ -1,25 +1,23 @@ # frozen_string_literal: true class CustomIssueTrackerService < IssueTrackerService + include ActionView::Helpers::UrlHelper validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? def title - 'Custom Issue Tracker' + s_('IssueTracker|Custom issue tracker') end def description - s_('IssueTracker|Custom issue tracker') + s_("IssueTracker|Use a custom issue tracker as this project's issue tracker.") end - def self.to_param - 'custom_issue_tracker' + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/custom_issue_tracker'), target: '_blank', rel: 'noopener noreferrer' + s_('IssueTracker|Use a custom issue tracker that is not in the integration list. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end - def fields - [ - { type: 'text', name: 'project_url', title: _('Project URL'), required: true }, - { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), required: true }, - { type: 'text', name: 'new_issue_url', title: s_('ProjectService|New issue URL'), required: true } - ] + def self.to_param + 'custom_issue_tracker' end end diff --git a/app/models/project_services/data_fields.rb b/app/models/project_services/data_fields.rb index 12ebf260e08..ca4dc0375fb 100644 --- a/app/models/project_services/data_fields.rb +++ b/app/models/project_services/data_fields.rb @@ -42,9 +42,9 @@ module DataFields end included do - has_one :issue_tracker_data, autosave: true - has_one :jira_tracker_data, autosave: true - has_one :open_project_tracker_data, autosave: true + has_one :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id + has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id + has_one :open_project_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id def data_fields raise NotImplementedError diff --git a/app/models/project_services/datadog_service.rb b/app/models/project_services/datadog_service.rb deleted file mode 100644 index 9a2d99c46c9..00000000000 --- a/app/models/project_services/datadog_service.rb +++ /dev/null @@ -1,144 +0,0 @@ -# frozen_string_literal: true - -class DatadogService < Service - DEFAULT_SITE = 'datadoghq.com' - URL_TEMPLATE = 'https://webhooks-http-intake.logs.%{datadog_site}/v1/input/' - URL_TEMPLATE_API_KEYS = 'https://app.%{datadog_site}/account/settings#api' - URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_SITE}/account_management/api-app-keys/" - - SUPPORTED_EVENTS = %w[ - pipeline job - ].freeze - - prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env - - with_options if: :activated? do - validates :api_key, presence: true, format: { with: /\A\w+\z/ } - validates :datadog_site, format: { with: /\A[\w\.]+\z/, allow_blank: true } - validates :api_url, public_url: { allow_blank: true } - validates :datadog_site, presence: true, unless: -> (obj) { obj.api_url.present? } - validates :api_url, presence: true, unless: -> (obj) { obj.datadog_site.present? } - end - - after_save :compose_service_hook, if: :activated? - - def initialize_properties - super - - self.datadog_site ||= DEFAULT_SITE - end - - def self.supported_events - SUPPORTED_EVENTS - end - - def self.default_test_event - 'pipeline' - end - - def configurable_events - [] # do not allow to opt out of required hooks - end - - def title - 'Datadog' - end - - def description - 'Trace your GitLab pipelines with Datadog' - end - - def help - nil - # Maybe adding something in the future - # We could link to static help pages as well - # [More information](#{Gitlab::Routing.url_helpers.help_page_url('integration/datadog')})" - end - - def self.to_param - 'datadog' - end - - def fields - [ - { - type: 'text', - name: 'datadog_site', - placeholder: DEFAULT_SITE, - help: 'Choose the Datadog site to send data to. Set to "datadoghq.eu" to send data to the EU site', - required: false - }, - { - type: 'text', - name: 'api_url', - title: 'API URL', - help: '(Advanced) Define the full URL for your Datadog site directly', - required: false - }, - { - type: 'password', - name: 'api_key', - title: _('API key'), - non_empty_password_title: s_('ProjectService|Enter new API key'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'), - help: "<a href=\"#{api_keys_url}\" target=\"_blank\">API key</a> used for authentication with Datadog", - required: true - }, - { - type: 'text', - name: 'datadog_service', - title: 'Service', - placeholder: 'gitlab-ci', - help: 'Name of this GitLab instance that all data will be tagged with' - }, - { - type: 'text', - name: 'datadog_env', - title: 'Env', - help: 'The environment tag that traces will be tagged with' - } - ] - end - - def compose_service_hook - hook = service_hook || build_service_hook - hook.url = hook_url - hook.save - end - - def hook_url - url = api_url.presence || sprintf(URL_TEMPLATE, datadog_site: datadog_site) - url = URI.parse(url) - url.path = File.join(url.path || '/', api_key) - query = { service: datadog_service.presence, env: datadog_env.presence }.compact - url.query = query.to_query unless query.empty? - url.to_s - end - - def api_keys_url - return URL_API_KEYS_DOCS unless datadog_site.presence - - sprintf(URL_TEMPLATE_API_KEYS, datadog_site: datadog_site) - end - - def execute(data) - return if project.disabled_services.include?(to_param) - - object_kind = data[:object_kind] - object_kind = 'job' if object_kind == 'build' - return unless supported_events.include?(object_kind) - - service_hook.execute(data, "#{object_kind} hook") - end - - def test(data) - begin - result = execute(data) - return { success: false, result: result[:message] } if result[:http_status] != 200 - rescue StandardError => error - return { success: false, result: error } - end - - { success: true, result: result[:message] } - end -end diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb deleted file mode 100644 index cdb69684d16..00000000000 --- a/app/models/project_services/emails_on_push_service.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -class EmailsOnPushService < Service - include NotificationBranchSelection - - RECIPIENTS_LIMIT = 750 - - boolean_accessor :send_from_committer_email - boolean_accessor :disable_diffs - prop_accessor :recipients, :branches_to_be_notified - validates :recipients, presence: true, if: :validate_recipients? - validate :number_of_recipients_within_limit, if: :validate_recipients? - - def self.valid_recipients(recipients) - recipients.split.select do |recipient| - recipient.include?('@') - end.uniq(&:downcase) - end - - def title - s_('EmailsOnPushService|Emails on push') - end - - def description - s_('EmailsOnPushService|Email the commits and diff of each push to a list of recipients.') - end - - def self.to_param - 'emails_on_push' - end - - def self.supported_events - %w(push tag_push) - end - - def initialize_properties - super - - self.branches_to_be_notified = 'all' if branches_to_be_notified.nil? - end - - def execute(push_data) - return unless supported_events.include?(push_data[:object_kind]) - return if project.emails_disabled? - return unless notify_for_ref?(push_data) - - EmailsOnPushWorker.perform_async( - project_id, - recipients, - push_data, - send_from_committer_email: send_from_committer_email?, - disable_diffs: disable_diffs? - ) - end - - def notify_for_ref?(push_data) - return true if push_data[:object_kind] == 'tag_push' - return true if push_data.dig(:object_attributes, :tag) - - notify_for_branch?(push_data) - end - - def send_from_committer_email? - Gitlab::Utils.to_boolean(self.send_from_committer_email) - end - - def disable_diffs? - Gitlab::Utils.to_boolean(self.disable_diffs) - end - - def fields - domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ") - [ - { type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"), - help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } }, - { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), - help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }, - { - type: 'textarea', - name: 'recipients', - placeholder: s_('EmailsOnPushService|tanuki@example.com gitlab@example.com'), - help: s_('EmailsOnPushService|Emails separated by whitespace.') - } - ] - end - - private - - def number_of_recipients_within_limit - return if recipients.blank? - - if self.class.valid_recipients(recipients).size > RECIPIENTS_LIMIT - errors.add(:recipients, s_("EmailsOnPushService|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT }) - end - end -end diff --git a/app/models/project_services/ewm_service.rb b/app/models/project_services/ewm_service.rb index af402e50292..90fcbb10d2b 100644 --- a/app/models/project_services/ewm_service.rb +++ b/app/models/project_services/ewm_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class EwmService < IssueTrackerService + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? def self.reference_pattern(only_long: true) @@ -12,7 +14,12 @@ class EwmService < IssueTrackerService end def description - s_('IssueTracker|EWM work items tracker') + s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/ewm'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end def self.to_param diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb index c41783d1af4..f49b008533d 100644 --- a/app/models/project_services/external_wiki_service.rb +++ b/app/models/project_services/external_wiki_service.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true -class ExternalWikiService < Service +class ExternalWikiService < Integration include ActionView::Helpers::UrlHelper + prop_accessor :external_wiki_url validates :external_wiki_url, presence: true, public_url: true, if: :activated? @@ -39,7 +40,7 @@ class ExternalWikiService < Service def execute(_data) response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) response.body if response.code == 200 - rescue + rescue StandardError nil end diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb index e721fded1d9..7aae5af7454 100644 --- a/app/models/project_services/flowdock_service.rb +++ b/app/models/project_services/flowdock_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -class FlowdockService < Service +class FlowdockService < Integration + include ActionView::Helpers::UrlHelper + prop_accessor :token validates :token, presence: true, if: :activated? @@ -9,7 +11,12 @@ class FlowdockService < Service end def description - s_('FlowdockService|Flowdock is a collaboration web app for technical teams.') + s_('FlowdockService|Send event notifications from GitLab to Flowdock flows.') + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'flowdock'), target: '_blank', rel: 'noopener noreferrer' + s_('FlowdockService|Send event notifications from GitLab to Flowdock flows. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end def self.to_param @@ -18,7 +25,7 @@ class FlowdockService < Service def fields [ - { type: 'text', name: 'token', placeholder: s_('FlowdockService|Flowdock Git source token'), required: true } + { type: 'text', name: 'token', placeholder: s_('FlowdockService|1b609b52537...'), required: true, help: 'Enter your Flowdock token.' } ] end diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb index 299a306add7..6e7708a169f 100644 --- a/app/models/project_services/hangouts_chat_service.rb +++ b/app/models/project_services/hangouts_chat_service.rb @@ -3,12 +3,14 @@ require 'hangouts_chat' class HangoutsChatService < ChatNotificationService + include ActionView::Helpers::UrlHelper + def title - 'Hangouts Chat' + 'Google Chat' end def description - 'Receive event notifications in Google Hangouts Chat' + 'Send notifications from GitLab to a room in Google Chat.' end def self.to_param @@ -16,13 +18,8 @@ class HangoutsChatService < ChatNotificationService end def help - 'This service sends notifications about projects events to Google Hangouts Chat room.<br /> - To set up this service: - <ol> - <li><a href="https://developers.google.com/hangouts/chat/how-tos/webhooks">Set up an incoming webhook for your room</a>. All notifications will come to this room.</li> - <li>Paste the <strong>Webhook URL</strong> into the field below.</li> - <li>Select events below to enable notifications.</li> - </ol>' + docs_link = link_to _('How do I set up a Google Chat webhook?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), target: '_blank', rel: 'noopener noreferrer' + s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end def event_field(event) @@ -42,7 +39,7 @@ class HangoutsChatService < ChatNotificationService def default_fields [ - { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, + { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index cd49c6d253d..71d8e7bfac4 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -1,54 +1,17 @@ # frozen_string_literal: true -class HipchatService < Service - include ActionView::Helpers::SanitizeHelper - - MAX_COMMITS = 3 - HIPCHAT_ALLOWED_TAGS = %w[ - a b i strong em br img pre code - table th tr td caption colgroup col thead tbody tfoot - ul ol li dl dt dd - ].freeze - - prop_accessor :token, :room, :server, :color, :api_version - boolean_accessor :notify_only_broken_pipelines, :notify - validates :token, presence: true, if: :activated? - - def initialize_properties - if properties.nil? - self.properties = {} - self.notify_only_broken_pipelines = true - end - end - - def title - 'HipChat' - end - - def description - 'Private group chat and IM' - end +# This service is scheduled for removal. All records must +# be deleted before the class can be removed. +# https://gitlab.com/gitlab-org/gitlab/-/issues/27954 +class HipchatService < Integration + before_save :prevent_save def self.to_param 'hipchat' end - def fields - [ - { type: 'text', name: 'token', placeholder: 'Room token', required: true }, - { type: 'text', name: 'room', placeholder: 'Room name or ID' }, - { type: 'checkbox', name: 'notify' }, - { type: 'select', name: 'color', choices: %w(yellow red green purple gray random) }, - { type: 'text', name: 'api_version', title: _('API version'), - placeholder: 'Leave blank for default (v2)' }, - { type: 'text', name: 'server', - placeholder: 'Leave blank for default. https://hipchat.example.com' }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' } - ] - end - def self.supported_events - %w(push issue confidential_issue merge_request note confidential_note tag_push pipeline) + [] end def execute(data) @@ -56,96 +19,14 @@ class HipchatService < Service # HipChat is unusable anyway, so do nothing in this method end - def test(data) - begin - result = execute(data) - rescue StandardError => error - return { success: false, result: error } - end - - { success: true, result: result } - end - private - def message_options(data = nil) - { notify: notify.present? && Gitlab::Utils.to_boolean(notify), color: message_color(data) } - end - - def render_line(text) - markdown(text.lines.first.chomp, pipeline: :single_line) if text - end - - def markdown(text, options = {}) - return "" unless text - - context = { - project: project, - pipeline: :email - } - - Banzai.render(text, context) - - context.merge!(options) - - html = Banzai.render_and_post_process(text, context) - sanitized_html = sanitize(html, tags: HIPCHAT_ALLOWED_TAGS, attributes: %w[href title alt]) - - sanitized_html.truncate(200, separator: ' ', omission: '...') - end - - def format_title(title) - "<b>#{render_line(title)}</b>" - end - - def message_color(data) - pipeline_status_color(data) || color || 'yellow' - end - - def pipeline_status_color(data) - return unless data && data[:object_kind] == 'pipeline' - - case data[:object_attributes][:status] - when 'success' - 'green' - else - 'red' - end - end - - def project_name - project.full_name.gsub(/\s/, '') - end - - def project_url - project.web_url - end - - def project_link - "<a href=\"#{project_url}\">#{project_name}</a>" - end - - def update?(data) - data[:object_attributes][:action] == 'update' - end - - def humanized_status(status) - case status - when 'success' - 'passed' - else - status - end - end + def prevent_save + errors.add(:base, _('HipChat endpoint is deprecated and should not be created or modified.')) - def should_pipeline_be_notified?(data) - case data[:object_attributes][:status] - when 'success' - !notify_only_broken_pipelines? - when 'failed' - true - else - false - end + # Stops execution of callbacks and database operation while + # preserving expectations of #save (will not raise) & #save! (raises) + # https://guides.rubyonrails.org/active_record_callbacks.html#halting-execution + throw :abort # rubocop:disable Cop/BanCatchThrow end end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index 4f1ce16ebb2..5cca620c659 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -2,7 +2,7 @@ require 'uri' -class IrkerService < Service +class IrkerService < Integration prop_accessor :server_host, :server_port, :default_irc_uri prop_accessor :recipients, :channels boolean_accessor :colorize_messages @@ -15,8 +15,7 @@ class IrkerService < Service end def description - 'Send IRC messages, on update, to a list of recipients through an Irker '\ - 'gateway.' + 'Send IRC messages.' end def self.to_param @@ -103,7 +102,7 @@ class IrkerService < Service begin new_recipient = URI.join(default_irc_uri, '/', recipient).to_s uri = consider_uri(URI.parse(new_recipient)) - rescue + rescue StandardError log_error("Unable to create a valid URL", default_irc_uri: default_irc_uri, recipient: recipient) end end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 19a5b4a74bb..099e3c336dd 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class IssueTrackerService < Service +class IssueTrackerService < Integration validate :one_issue_tracker, if: :activated?, on: :manual_change # TODO: we can probably just delegate as part of @@ -73,9 +73,9 @@ class IssueTrackerService < Service def fields [ - { type: 'text', name: 'project_url', title: _('Project URL'), required: true }, - { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), required: true }, - { type: 'text', name: 'new_issue_url', title: s_('ProjectService|New issue URL'), required: true } + { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in the external issue tracker.'), required: true }, + { type: 'text', name: 'issues_url', title: s_('IssueTracker|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true }, + { type: 'text', name: 'new_issue_url', title: s_('IssueTracker|New issue URL'), help: s_('IssueTracker|The URL to create an issue in the external issue tracker.'), required: true } ] end @@ -143,10 +143,10 @@ class IssueTrackerService < Service return if template? || instance? return if project.blank? - if project.services.external_issue_trackers.where.not(id: id).any? + if project.integrations.external_issue_trackers.where.not(id: id).any? errors.add(:base, _('Another issue tracker is already in use. Only one issue tracker service can be active at a time')) end end end -IssueTrackerService.prepend_if_ee('EE::IssueTrackerService') +IssueTrackerService.prepend_mod_with('IssueTrackerService') diff --git a/app/models/project_services/jenkins_service.rb b/app/models/project_services/jenkins_service.rb index 6a123517b84..990a35cd617 100644 --- a/app/models/project_services/jenkins_service.rb +++ b/app/models/project_services/jenkins_service.rb @@ -64,12 +64,12 @@ class JenkinsService < CiService end def description - s_('An extendable open source CI/CD server.') + s_('Run CI/CD pipelines with Jenkins.') end def help docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer' - s_('Trigger Jenkins builds when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + s_('Run CI/CD pipelines with Jenkins when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end def self.to_param diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 3e14bf44c12..5cd6e79eb1d 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -106,9 +106,8 @@ class JiraService < IssueTrackerService end def help - "You need to configure Jira before enabling this service. For more details - read the - [Jira service documentation](#{help_page_url('user/project/integrations/jira')})." + jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('integration/jira/index.html') } + s_("JiraService|You need to configure Jira before enabling this integration. For more details, read the %{jira_doc_link_start}Jira integration documentation%{link_end}.") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe } end def title @@ -116,7 +115,7 @@ class JiraService < IssueTrackerService end def description - s_('JiraService|Track issues in Jira') + s_("JiraService|Use Jira as this project's issue tracker.") end def self.to_param @@ -305,7 +304,7 @@ class JiraService < IssueTrackerService ) true - rescue => error + rescue StandardError => error log_error( "Issue transition failed", error: { @@ -490,7 +489,7 @@ class JiraService < IssueTrackerService # Handle errors when doing Jira API calls def jira_request yield - rescue => error + rescue StandardError => error @error = error log_error("Error sending message", client_url: client_url, error: @error.message) nil @@ -539,4 +538,4 @@ class JiraService < IssueTrackerService end end -JiraService.prepend_if_ee('EE::JiraService') +JiraService.prepend_mod_with('JiraService') diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb index 803c1255195..1d2067067da 100644 --- a/app/models/project_services/microsoft_teams_service.rb +++ b/app/models/project_services/microsoft_teams_service.rb @@ -6,7 +6,7 @@ class MicrosoftTeamsService < ChatNotificationService end def description - 'Receive event notifications in Microsoft Teams' + 'Send notifications about project events to Microsoft Teams.' end def self.to_param diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb index 1b530a8247b..ea65a200027 100644 --- a/app/models/project_services/monitoring_service.rb +++ b/app/models/project_services/monitoring_service.rb @@ -4,7 +4,7 @@ # # These services integrate with a deployment solution like Prometheus # to provide additional features for environments. -class MonitoringService < Service +class MonitoringService < Integration default_value_for :category, 'monitoring' def self.supported_events diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb index 21f0a2b2463..f3ea8c64302 100644 --- a/app/models/project_services/packagist_service.rb +++ b/app/models/project_services/packagist_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PackagistService < Service +class PackagistService < Integration prop_accessor :username, :token, :server validates :username, presence: true, if: :activated? @@ -16,7 +16,7 @@ class PackagistService < Service end def description - s_('Integrations|Update your projects on Packagist, the main Composer repository') + s_('Integrations|Update your Packagist projects.') end def self.to_param diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index 0a0a41c525c..4603193ac8e 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PipelinesEmailService < Service +class PipelinesEmailService < Integration include NotificationBranchSelection prop_accessor :recipients, :branches_to_be_notified diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb index d3fff100964..6e67984591d 100644 --- a/app/models/project_services/pivotaltracker_service.rb +++ b/app/models/project_services/pivotaltracker_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PivotaltrackerService < Service +class PivotaltrackerService < Integration API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' prop_accessor :token, :restrict_to_branch @@ -11,7 +11,7 @@ class PivotaltrackerService < Service end def description - s_('PivotalTrackerService|Project Management Software (Source Commits Endpoint)') + s_('PivotalTrackerService|Add commit messages as comments to PivotalTracker stories.') end def self.to_param diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb index 1781ec7456d..89765fbdf41 100644 --- a/app/models/project_services/pushover_service.rb +++ b/app/models/project_services/pushover_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PushoverService < Service +class PushoverService < Integration BASE_URI = 'https://api.pushover.net/1' prop_accessor :api_key, :user_key, :device, :priority, :sound @@ -11,7 +11,7 @@ class PushoverService < Service end def description - s_('PushoverService|Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop.') + s_('PushoverService|Get real-time notifications on your device.') end def self.to_param diff --git a/app/models/project_services/redmine_service.rb b/app/models/project_services/redmine_service.rb index 26a6cf86bf4..7a0f500209c 100644 --- a/app/models/project_services/redmine_service.rb +++ b/app/models/project_services/redmine_service.rb @@ -9,7 +9,7 @@ class RedmineService < IssueTrackerService end def description - s_('IssueTracker|Use Redmine as the issue tracker.') + s_("IssueTracker|Use Redmine as this project's issue tracker.") end def help diff --git a/app/models/project_services/slack_service.rb b/app/models/project_services/slack_service.rb index 7badcc24870..92a46f8d01f 100644 --- a/app/models/project_services/slack_service.rb +++ b/app/models/project_services/slack_service.rb @@ -39,7 +39,7 @@ class SlackService < ChatNotificationService end def get_message(object_kind, data) - return ChatMessage::AlertMessage.new(data) if object_kind == 'alert' + return Integrations::ChatMessage::AlertMessage.new(data) if object_kind == 'alert' super end diff --git a/app/models/project_services/slash_commands_service.rb b/app/models/project_services/slash_commands_service.rb index d436176a52c..37d16737052 100644 --- a/app/models/project_services/slash_commands_service.rb +++ b/app/models/project_services/slash_commands_service.rb @@ -2,7 +2,7 @@ # Base class for Chat services # This class is not meant to be used directly, but only to inherrit from. -class SlashCommandsService < Service +class SlashCommandsService < Integration default_value_for :category, 'chat' prop_accessor :token diff --git a/app/models/project_services/unify_circuit_service.rb b/app/models/project_services/unify_circuit_service.rb index 1a0eebe7d64..5f43388e1c9 100644 --- a/app/models/project_services/unify_circuit_service.rb +++ b/app/models/project_services/unify_circuit_service.rb @@ -6,7 +6,7 @@ class UnifyCircuitService < ChatNotificationService end def description - 'Receive event notifications in Unify Circuit' + s_('Integrations|Send notifications about project events to Unify Circuit.') end def self.to_param diff --git a/app/models/project_services/webex_teams_service.rb b/app/models/project_services/webex_teams_service.rb index 4e8281f4e81..3d92d3bb85e 100644 --- a/app/models/project_services/webex_teams_service.rb +++ b/app/models/project_services/webex_teams_service.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true class WebexTeamsService < ChatNotificationService + include ActionView::Helpers::UrlHelper + def title - 'Webex Teams' + s_("WebexTeamsService|Webex Teams") end def description - 'Receive event notifications in Webex Teams' + s_("WebexTeamsService|Send notifications about project events to Webex Teams.") end def self.to_param @@ -14,13 +16,8 @@ class WebexTeamsService < ChatNotificationService end def help - 'This service sends notifications about projects events to a Webex Teams conversation.<br /> - To set up this service: - <ol> - <li><a href="https://apphub.webex.com/teams/applications/incoming-webhooks-cisco-systems">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li> - <li>Paste the <strong>Webhook URL</strong> into the field below.</li> - <li>Select events below to enable notifications.</li> - </ol>' + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer' + s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe } end def event_field(event) @@ -36,7 +33,7 @@ class WebexTeamsService < ChatNotificationService def default_fields [ - { type: 'text', name: 'webhook', placeholder: "e.g. https://api.ciscospark.com/v1/webhooks/incoming/…", required: true }, + { type: 'text', name: 'webhook', placeholder: "https://api.ciscospark.com/v1/webhooks/incoming/...", required: true }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb index 30abd0159b3..9760a22a872 100644 --- a/app/models/project_services/youtrack_service.rb +++ b/app/models/project_services/youtrack_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class YoutrackService < IssueTrackerService + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 @@ -17,7 +19,12 @@ class YoutrackService < IssueTrackerService end def description - s_('IssueTracker|YouTrack issue tracker') + s_("IssueTracker|Use YouTrack as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end def self.to_param @@ -26,8 +33,8 @@ class YoutrackService < IssueTrackerService def fields [ - { type: 'text', name: 'project_url', title: _('Project URL'), required: true }, - { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), required: true } + { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in YouTrack.'), required: true }, + { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the YouTrack project. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true } ] end end diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index 83ff0702b88..24d892290a6 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -21,4 +21,4 @@ class ProjectSetting < ApplicationRecord end end -ProjectSetting.prepend_ee_mod +ProjectSetting.prepend_mod diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 8c3dcaa7c0f..37ddd2d030d 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -159,4 +159,4 @@ class ProjectStatistics < ApplicationRecord end end -ProjectStatistics.prepend_if_ee('EE::ProjectStatistics') +ProjectStatistics.prepend_mod_with('ProjectStatistics') diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 1a3f362e6a1..a85afada901 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -130,7 +130,7 @@ class ProjectTeam end true - rescue + rescue StandardError false end @@ -234,4 +234,4 @@ class ProjectTeam end end -ProjectTeam.prepend_if_ee('EE::ProjectTeam') +ProjectTeam.prepend_mod_with('ProjectTeam') diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 91fb3d4e4ba..ffffa803011 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -32,4 +32,4 @@ end # TODO: Remove this once we implement ES support for group wikis. # https://gitlab.com/gitlab-org/gitlab/-/issues/207889 -ProjectWiki.prepend_if_ee('EE::ProjectWiki') +ProjectWiki.prepend_mod_with('ProjectWiki') diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 963a6b7774a..889eaed138d 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -63,4 +63,4 @@ class ProtectedBranch < ApplicationRecord end end -ProtectedBranch.prepend_if_ee('EE::ProtectedBranch') +ProtectedBranch.prepend_mod_with('ProtectedBranch') diff --git a/app/models/push_event_payload.rb b/app/models/push_event_payload.rb index 2786ecb641a..8358be35470 100644 --- a/app/models/push_event_payload.rb +++ b/app/models/push_event_payload.rb @@ -25,4 +25,4 @@ class PushEventPayload < ApplicationRecord } end -PushEventPayload.prepend_if_ee('EE::PushEventPayload') +PushEventPayload.prepend_mod_with('PushEventPayload') diff --git a/app/models/release.rb b/app/models/release.rb index 5ca8f537baa..1889a0707b4 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -13,6 +13,7 @@ class Release < ApplicationRecord belongs_to :author, class_name: 'User' has_many :links, class_name: 'Releases::Link' + has_many :sorted_links, -> { sorted }, class_name: 'Releases::Link', inverse_of: :release has_many :milestone_releases has_many :milestones, through: :milestone_releases @@ -23,11 +24,15 @@ class Release < ApplicationRecord before_create :set_released_at validates :project, :tag, presence: true + validates :description, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }, if: :description_changed? validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } validates :links, nested_attributes_duplicates: { scope: :release, child_attributes: %i[name url filepath] } scope :sorted, -> { order(released_at: :desc) } - scope :preloaded, -> { includes(:evidences, :milestones, project: [:project_feature, :route, { namespace: :route }]) } + scope :preloaded, -> { + includes(:author, :evidences, :milestones, :links, :sorted_links, + project: [:project_feature, :route, { namespace: :route }]) + } scope :with_project_and_namespace, -> { includes(project: :namespace) } scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) } @@ -58,8 +63,8 @@ class Release < ApplicationRecord end def assets_count(except: []) - links_count = links.count - sources_count = except.include?(:sources) ? 0 : sources.count + links_count = links.size + sources_count = except.include?(:sources) ? 0 : sources.size links_count + sources_count end @@ -123,4 +128,4 @@ class Release < ApplicationRecord end end -Release.prepend_if_ee('EE::Release') +Release.prepend_mod_with('Release') diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb index 98d9899a349..9c30d0611e6 100644 --- a/app/models/release_highlight.rb +++ b/app/models/release_highlight.rb @@ -4,6 +4,10 @@ class ReleaseHighlight CACHE_DURATION = 1.hour FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml') + FREE_PACKAGE = 'Free' + PREMIUM_PACKAGE = 'Premium' + ULTIMATE_PACKAGE = 'Ultimate' + def self.paginated(page: 1) key = self.cache_key("items:page-#{page}") @@ -25,14 +29,12 @@ class ReleaseHighlight file = File.read(file_path) items = YAML.safe_load(file, permitted_classes: [Date]) - platform = Gitlab.com? ? 'gitlab-com' : 'self-managed' - items&.map! do |item| - next unless item[platform] + next unless include_item?(item) begin item.tap {|i| i['body'] = Kramdown::Document.new(i['body']).to_html } - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, file_path: file_path) next @@ -53,7 +55,8 @@ class ReleaseHighlight end def self.cache_key(key) - ['release_highlight', key, Gitlab.revision].join(':') + variant = Gitlab::CurrentSettings.current_application_settings.whats_new_variant + ['release_highlight', variant, key, Gitlab.revision].join(':') end def self.next_page(current_page: 1) @@ -88,4 +91,27 @@ class ReleaseHighlight delegate :each, to: :items end + + def self.current_package + return FREE_PACKAGE unless defined?(License) + + case License.current&.plan&.downcase + when License::PREMIUM_PLAN + PREMIUM_PACKAGE + when License::ULTIMATE_PLAN + ULTIMATE_PACKAGE + else + FREE_PACKAGE + end + end + + def self.include_item?(item) + platform = Gitlab.com? ? 'gitlab-com' : 'self-managed' + + return false unless item[platform] + + return true unless Gitlab::CurrentSettings.current_application_settings.whats_new_variant_current_tier? + + item['packages']&.include?(current_package) + end end diff --git a/app/models/releases/evidence.rb b/app/models/releases/evidence.rb index 7c428f5ad03..5fe91b0fef5 100644 --- a/app/models/releases/evidence.rb +++ b/app/models/releases/evidence.rb @@ -5,7 +5,7 @@ module Releases include ShaAttribute include Presentable - belongs_to :release, inverse_of: :evidences + belongs_to :release, inverse_of: :evidences, touch: true default_scope { order(created_at: :asc) } # rubocop:disable Cop/DefaultScope diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb index fc2fa639f56..acc56d3980a 100644 --- a/app/models/releases/link.rb +++ b/app/models/releases/link.rb @@ -4,7 +4,7 @@ module Releases class Link < ApplicationRecord self.table_name = 'release_links' - belongs_to :release + belongs_to :release, touch: true # See https://gitlab.com/gitlab-org/gitlab/-/issues/218753 # Regex modified to prevent catastrophic backtracking diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index c7387d2197d..c3ca90ca0ad 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -214,7 +214,7 @@ class RemoteMirror < ApplicationRecord if super Gitlab::UrlSanitizer.new(super, credentials: credentials).full_url end - rescue + rescue StandardError super end @@ -275,7 +275,7 @@ class RemoteMirror < ApplicationRecord return url unless ssh_key_auth? && password.present? Gitlab::UrlSanitizer.new(read_attribute(:url), credentials: { user: user }).full_url - rescue + rescue StandardError super end @@ -339,4 +339,4 @@ class RemoteMirror < ApplicationRecord end end -RemoteMirror.prepend_if_ee('EE::RemoteMirror') +RemoteMirror.prepend_mod_with('RemoteMirror') diff --git a/app/models/repository.rb b/app/models/repository.rb index b2efc9b480b..7dca8e52403 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -995,7 +995,13 @@ class Repository def search_files_by_wildcard_path(path, ref = 'HEAD') # We need to use RE2 to match Gitaly's regexp engine - regexp_string = RE2::Regexp.escape(path).gsub('\*', '.*?') + regexp_string = RE2::Regexp.escape(path) + + anything = '.*?' + anything_but_not_slash = '([^\/])*?' + regexp_string.gsub!('\*\*', anything) + regexp_string.gsub!('\*', anything_but_not_slash) + raw_repository.search_files_by_regexp("^#{regexp_string}$", ref) end @@ -1165,17 +1171,13 @@ class Repository end def tags_sorted_by_committed_date - tags.sort_by do |tag| - # Annotated tags can point to any object (e.g. a blob), but generally - # tags point to a commit. If we don't have a commit, then just default - # to putting the tag at the end of the list. - target = tag.dereferenced_target + # Annotated tags can point to any object (e.g. a blob), but generally + # tags point to a commit. If we don't have a commit, then just default + # to putting the tag at the end of the list. + default = Time.current - if target - target.committed_date - else - Time.current - end + tags.sort_by do |tag| + tag.dereferenced_target&.committed_date || default end end @@ -1191,4 +1193,4 @@ class Repository end end -Repository.prepend_if_ee('EE::Repository') +Repository.prepend_mod_with('Repository') diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index 57a3b568c53..68f0ab06bea 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -115,4 +115,4 @@ class ResourceLabelEvent < ResourceEvent end end -ResourceLabelEvent.prepend_if_ee('EE::ResourceLabelEvent') +ResourceLabelEvent.prepend_mod_with('ResourceLabelEvent') diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb index 73eb4987143..689a9d8a8ae 100644 --- a/app/models/resource_state_event.rb +++ b/app/models/resource_state_event.rb @@ -45,4 +45,4 @@ class ResourceStateEvent < ResourceEvent end end -ResourceStateEvent.prepend_if_ee('EE::ResourceStateEvent') +ResourceStateEvent.prepend_mod_with('ResourceStateEvent') diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb index 71077758b69..db87ff09159 100644 --- a/app/models/resource_timebox_event.rb +++ b/app/models/resource_timebox_event.rb @@ -41,4 +41,4 @@ class ResourceTimeboxEvent < ResourceEvent end end -ResourceTimeboxEvent.prepend_if_ee('EE::ResourceTimeboxEvent') +ResourceTimeboxEvent.prepend_mod_with('ResourceTimeboxEvent') diff --git a/app/models/serverless/domain_cluster.rb b/app/models/serverless/domain_cluster.rb index 9f914d5c3f8..0d54a97370e 100644 --- a/app/models/serverless/domain_cluster.rb +++ b/app/models/serverless/domain_cluster.rb @@ -12,7 +12,7 @@ module Serverless attr_encrypted :key, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' validates :pages_domain, :knative, presence: true diff --git a/app/models/service_list.rb b/app/models/service_list.rb index 5eca5f2bda1..8a52539d128 100644 --- a/app/models/service_list.rb +++ b/app/models/service_list.rb @@ -8,7 +8,7 @@ class ServiceList end def to_array - [Service, columns, values] + [Integration, columns, values] end private diff --git a/app/models/sidebars/context.rb b/app/models/sidebars/context.rb deleted file mode 100644 index d9ac2705aaf..00000000000 --- a/app/models/sidebars/context.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -# This class stores all the information needed to display and -# render the sidebar and menus. -# It usually stores information regarding the context and calculated -# values where the logic is in helpers. -module Sidebars - class Context - attr_reader :current_user, :container - - def initialize(current_user:, container:, **args) - @current_user = current_user - @container = container - - args.each do |key, value| - singleton_class.public_send(:attr_reader, key) # rubocop:disable GitlabSecurity/PublicSend - instance_variable_set("@#{key}", value) - end - end - end -end diff --git a/app/models/sidebars/menu.rb b/app/models/sidebars/menu.rb deleted file mode 100644 index a5c8be2bb31..00000000000 --- a/app/models/sidebars/menu.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - class Menu - extend ::Gitlab::Utils::Override - include ::Gitlab::Routing - include GitlabRoutingHelper - include Gitlab::Allowable - include ::Sidebars::HasPill - include ::Sidebars::HasIcon - include ::Sidebars::PositionableList - include ::Sidebars::Renderable - include ::Sidebars::ContainerWithHtmlOptions - include ::Sidebars::HasActiveRoutes - - attr_reader :context - delegate :current_user, :container, to: :@context - - def initialize(context) - @context = context - @items = [] - - configure_menu_items - end - - def configure_menu_items - # No-op - end - - override :render? - def render? - @items.empty? || renderable_items.any? - end - - # Menus might have or not a link - override :link - def link - nil - end - - # This method normalizes the information retrieved from the submenus and this menu - # Value from menus is something like: [{ path: 'foo', path: 'bar', controller: :foo }] - # This method filters the information and returns: { path: ['foo', 'bar'], controller: :foo } - def all_active_routes - @all_active_routes ||= begin - ([active_routes] + renderable_items.map(&:active_routes)).flatten.each_with_object({}) do |pairs, hash| - pairs.each do |k, v| - hash[k] ||= [] - hash[k] += Array(v) - hash[k].uniq! - end - - hash - end - end - end - - def has_items? - @items.any? - end - - def add_item(item) - add_element(@items, item) - end - - def insert_item_before(before_item, new_item) - insert_element_before(@items, before_item, new_item) - end - - def insert_item_after(after_item, new_item) - insert_element_after(@items, after_item, new_item) - end - - def has_renderable_items? - renderable_items.any? - end - - def renderable_items - @renderable_items ||= @items.select(&:render?) - end - end -end diff --git a/app/models/sidebars/menu_item.rb b/app/models/sidebars/menu_item.rb deleted file mode 100644 index 7466b31898e..00000000000 --- a/app/models/sidebars/menu_item.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - class MenuItem - extend ::Gitlab::Utils::Override - include ::Gitlab::Routing - include GitlabRoutingHelper - include Gitlab::Allowable - include ::Sidebars::HasIcon - include ::Sidebars::HasHint - include ::Sidebars::Renderable - include ::Sidebars::ContainerWithHtmlOptions - include ::Sidebars::HasActiveRoutes - - attr_reader :context - - def initialize(context) - @context = context - end - end -end diff --git a/app/models/sidebars/panel.rb b/app/models/sidebars/panel.rb deleted file mode 100644 index 5c8191ebda3..00000000000 --- a/app/models/sidebars/panel.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - class Panel - extend ::Gitlab::Utils::Override - include ::Sidebars::PositionableList - - attr_reader :context, :scope_menu, :hidden_menu - - def initialize(context) - @context = context - @scope_menu = nil - @hidden_menu = nil - @menus = [] - - configure_menus - end - - def configure_menus - # No-op - end - - def add_menu(menu) - add_element(@menus, menu) - end - - def insert_menu_before(before_menu, new_menu) - insert_element_before(@menus, before_menu, new_menu) - end - - def insert_menu_after(after_menu, new_menu) - insert_element_after(@menus, after_menu, new_menu) - end - - def set_scope_menu(scope_menu) - @scope_menu = scope_menu - end - - def set_hidden_menu(hidden_menu) - @hidden_menu = hidden_menu - end - - def aria_label - raise NotImplementedError - end - - def has_renderable_menus? - renderable_menus.any? - end - - def renderable_menus - @renderable_menus ||= @menus.select(&:render?) - end - - def container - context.container - end - - # Auxiliar method that helps with the migration from - # regular views to the new logic - def render_raw_scope_menu_partial - # No-op - end - - # Auxiliar method that helps with the migration from - # regular views to the new logic. - # - # Any menu inside this partial will be added after - # all the menus added in the `configure_menus` - # method. - def render_raw_menus_partial - # No-op - end - end -end diff --git a/app/models/sidebars/projects/context.rb b/app/models/sidebars/projects/context.rb deleted file mode 100644 index 4c82309035d..00000000000 --- a/app/models/sidebars/projects/context.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - class Context < ::Sidebars::Context - def initialize(current_user:, container:, **args) - super(current_user: current_user, container: container, project: container, **args) - end - end - end -end diff --git a/app/models/sidebars/projects/menus/learn_gitlab/menu.rb b/app/models/sidebars/projects/menus/learn_gitlab/menu.rb deleted file mode 100644 index 4b572846d1a..00000000000 --- a/app/models/sidebars/projects/menus/learn_gitlab/menu.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module LearnGitlab - class Menu < ::Sidebars::Menu - override :link - def link - project_learn_gitlab_path(context.project) - end - - override :active_routes - def active_routes - { controller: :learn_gitlab } - end - - override :title - def title - _('Learn GitLab') - end - - override :extra_container_html_options - def nav_link_html_options - { class: 'home' } - end - - override :sprite_icon - def sprite_icon - 'home' - end - - override :render? - def render? - context.learn_gitlab_experiment_enabled - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/project_overview/menu.rb b/app/models/sidebars/projects/menus/project_overview/menu.rb deleted file mode 100644 index e6aa8ed159f..00000000000 --- a/app/models/sidebars/projects/menus/project_overview/menu.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module ProjectOverview - class Menu < ::Sidebars::Menu - override :configure_menu_items - def configure_menu_items - add_item(MenuItems::Details.new(context)) - add_item(MenuItems::Activity.new(context)) - add_item(MenuItems::Releases.new(context)) - end - - override :link - def link - project_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - class: 'shortcuts-project rspec-project-link' - } - end - - override :extra_container_html_options - def nav_link_html_options - { class: 'home' } - end - - override :title - def title - _('Project overview') - end - - override :sprite_icon - def sprite_icon - 'home' - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/project_overview/menu_items/activity.rb b/app/models/sidebars/projects/menus/project_overview/menu_items/activity.rb deleted file mode 100644 index 46d0f0bc43b..00000000000 --- a/app/models/sidebars/projects/menus/project_overview/menu_items/activity.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module ProjectOverview - module MenuItems - class Activity < ::Sidebars::MenuItem - override :link - def link - activity_project_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - class: 'shortcuts-project-activity' - } - end - - override :active_routes - def active_routes - { path: 'projects#activity' } - end - - override :title - def title - _('Activity') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/project_overview/menu_items/details.rb b/app/models/sidebars/projects/menus/project_overview/menu_items/details.rb deleted file mode 100644 index c40c2ed8fa2..00000000000 --- a/app/models/sidebars/projects/menus/project_overview/menu_items/details.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module ProjectOverview - module MenuItems - class Details < ::Sidebars::MenuItem - override :link - def link - project_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - aria: { label: _('Project details') }, - class: 'shortcuts-project' - } - end - - override :active_routes - def active_routes - { path: 'projects#show' } - end - - override :title - def title - _('Details') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/project_overview/menu_items/releases.rb b/app/models/sidebars/projects/menus/project_overview/menu_items/releases.rb deleted file mode 100644 index 5e8348f4398..00000000000 --- a/app/models/sidebars/projects/menus/project_overview/menu_items/releases.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module ProjectOverview - module MenuItems - class Releases < ::Sidebars::MenuItem - override :link - def link - project_releases_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - class: 'shortcuts-project-releases' - } - end - - override :render? - def render? - can?(context.current_user, :read_release, context.project) && !context.project.empty_repo? - end - - override :active_routes - def active_routes - { controller: :releases } - end - - override :title - def title - _('Releases') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu.rb b/app/models/sidebars/projects/menus/repository/menu.rb deleted file mode 100644 index f49a0479521..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - class Menu < ::Sidebars::Menu - override :configure_menu_items - def configure_menu_items - add_item(MenuItems::Files.new(context)) - add_item(MenuItems::Commits.new(context)) - add_item(MenuItems::Branches.new(context)) - add_item(MenuItems::Tags.new(context)) - add_item(MenuItems::Contributors.new(context)) - add_item(MenuItems::Graphs.new(context)) - add_item(MenuItems::Compare.new(context)) - end - - override :link - def link - project_tree_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - class: 'shortcuts-tree' - } - end - - override :title - def title - _('Repository') - end - - override :title_html_options - def title_html_options - { - id: 'js-onboarding-repo-link' - } - end - - override :sprite_icon - def sprite_icon - 'doc-text' - end - - override :render? - def render? - can?(context.current_user, :download_code, context.project) && - !context.project.empty_repo? - end - end - end - end - end -end - -Sidebars::Projects::Menus::Repository::Menu.prepend_if_ee('EE::Sidebars::Projects::Menus::Repository::Menu') diff --git a/app/models/sidebars/projects/menus/repository/menu_items/branches.rb b/app/models/sidebars/projects/menus/repository/menu_items/branches.rb deleted file mode 100644 index 4a62803dd2b..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/branches.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Branches < ::Sidebars::MenuItem - override :link - def link - project_branches_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - id: 'js-onboarding-branches-link' - } - end - - override :active_routes - def active_routes - { controller: :branches } - end - - override :title - def title - _('Branches') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/commits.rb b/app/models/sidebars/projects/menus/repository/menu_items/commits.rb deleted file mode 100644 index 647cf89133e..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/commits.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Commits < ::Sidebars::MenuItem - override :link - def link - project_commits_path(context.project, context.current_ref) - end - - override :extra_container_html_options - def extra_container_html_options - { - id: 'js-onboarding-commits-link' - } - end - - override :active_routes - def active_routes - { controller: %w(commit commits) } - end - - override :title - def title - _('Commits') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/compare.rb b/app/models/sidebars/projects/menus/repository/menu_items/compare.rb deleted file mode 100644 index 4812636b63f..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/compare.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Compare < ::Sidebars::MenuItem - override :link - def link - project_compare_index_path(context.project, from: context.project.repository.root_ref, to: context.current_ref) - end - - override :active_routes - def active_routes - { controller: :compare } - end - - override :title - def title - _('Compare') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/contributors.rb b/app/models/sidebars/projects/menus/repository/menu_items/contributors.rb deleted file mode 100644 index d60fd05bb64..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/contributors.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Contributors < ::Sidebars::MenuItem - override :link - def link - project_graph_path(context.project, context.current_ref) - end - - override :active_routes - def active_routes - { path: 'graphs#show' } - end - - override :title - def title - _('Contributors') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/files.rb b/app/models/sidebars/projects/menus/repository/menu_items/files.rb deleted file mode 100644 index 4989efe9fa5..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/files.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Files < ::Sidebars::MenuItem - override :link - def link - project_tree_path(context.project, context.current_ref) - end - - override :active_routes - def active_routes - { controller: %w[tree blob blame edit_tree new_tree find_file] } - end - - override :title - def title - _('Files') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/graphs.rb b/app/models/sidebars/projects/menus/repository/menu_items/graphs.rb deleted file mode 100644 index a57021be4d0..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/graphs.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Graphs < ::Sidebars::MenuItem - override :link - def link - project_network_path(context.project, context.current_ref) - end - - override :active_routes - def active_routes - { controller: :network } - end - - override :title - def title - _('Graph') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/tags.rb b/app/models/sidebars/projects/menus/repository/menu_items/tags.rb deleted file mode 100644 index d84bc89b93c..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/tags.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Tags < ::Sidebars::MenuItem - override :link - def link - project_tags_path(context.project) - end - - override :active_routes - def active_routes - { controller: :tags } - end - - override :title - def title - _('Tags') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/scope/menu.rb b/app/models/sidebars/projects/menus/scope/menu.rb deleted file mode 100644 index 3b699083f75..00000000000 --- a/app/models/sidebars/projects/menus/scope/menu.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Scope - class Menu < ::Sidebars::Menu - override :link - def link - project_path(context.project) - end - - override :title - def title - context.project.name - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/panel.rb b/app/models/sidebars/projects/panel.rb deleted file mode 100644 index ec4fac53a40..00000000000 --- a/app/models/sidebars/projects/panel.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - class Panel < ::Sidebars::Panel - override :configure_menus - def configure_menus - set_scope_menu(Sidebars::Projects::Menus::Scope::Menu.new(context)) - - add_menu(Sidebars::Projects::Menus::ProjectOverview::Menu.new(context)) - add_menu(Sidebars::Projects::Menus::LearnGitlab::Menu.new(context)) - add_menu(Sidebars::Projects::Menus::Repository::Menu.new(context)) - end - - override :render_raw_menus_partial - def render_raw_menus_partial - 'layouts/nav/sidebar/project_menus' - end - - override :aria_label - def aria_label - _('Project navigation') - end - end - end -end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 5fdd4551982..68957dd6b22 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -20,7 +20,6 @@ class Snippet < ApplicationRecord extend ::Gitlab::Utils::Override MAX_FILE_COUNT = 10 - MASTER_BRANCH = 'master' cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description @@ -118,7 +117,7 @@ class Snippet < ApplicationRecord def self.only_include_projects_visible_to(current_user = nil) levels = Gitlab::VisibilityLevel.levels_for_user(current_user) - joins(:project).where('projects.visibility_level IN (?)', levels) + joins(:project).where(projects: { visibility_level: levels }) end def self.only_include_projects_with_snippets_enabled(include_private: false) @@ -316,19 +315,19 @@ class Snippet < ApplicationRecord override :default_branch def default_branch - super || MASTER_BRANCH + super || Gitlab::DefaultBranch.value(object: project) end def repository_storage snippet_repository&.shard_name || Repository.pick_storage_shard end - # Repositories are created by default with the `master` branch. + # Repositories are created with a default branch. This branch + # can be different from the default branch set in the platform. # This method changes the `HEAD` file to point to the existing - # default branch in case it's not master. + # default branch in case it's different. def change_head_to_default_branch return unless repository.exists? - return if default_branch == MASTER_BRANCH # All snippets must have at least 1 file. Therefore, if # `HEAD` is empty is because it's pointing to the wrong # default branch @@ -391,4 +390,4 @@ class Snippet < ApplicationRecord end end -Snippet.prepend_if_ee('EE::Snippet') +Snippet.prepend_mod_with('Snippet') diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb index 54dbc579d54..92405a0d943 100644 --- a/app/models/snippet_repository.rb +++ b/app/models/snippet_repository.rb @@ -133,4 +133,4 @@ class SnippetRepository < ApplicationRecord end end -SnippetRepository.prepend_if_ee('EE::SnippetRepository') +SnippetRepository.prepend_mod_with('SnippetRepository') diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index 7e34988c7a0..bb928118edf 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -128,10 +128,10 @@ class SshHostKey def normalize_url(url) full_url = ::Addressable::URI.parse(url) - raise ArgumentError.new("Invalid URL") unless full_url&.scheme == 'ssh' + raise ArgumentError, "Invalid URL" unless full_url&.scheme == 'ssh' Addressable::URI.parse("ssh://#{full_url.host}:#{full_url.inferred_port}") rescue Addressable::URI::InvalidURIError - raise ArgumentError.new("Invalid URL") + raise ArgumentError, "Invalid URL" end end diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb index f643d52587e..092e5249a3e 100644 --- a/app/models/storage/legacy_project.rb +++ b/app/models/storage/legacy_project.rb @@ -34,7 +34,7 @@ module Storage begin gitlab_shell.mv_repository(repository_storage, "#{old_full_path}.wiki", "#{new_full_path}.wiki") return true - rescue => e + rescue StandardError => e Gitlab::AppLogger.error("Exception renaming #{old_full_path} -> #{new_full_path}: #{e}") # Returning false does not rollback after_* transaction but gives # us information about failing some of tasks diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 20107147b4f..749b9dce97c 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -41,4 +41,4 @@ class SystemNoteMetadata < ApplicationRecord end end -SystemNoteMetadata.prepend_if_ee('EE::SystemNoteMetadata') +SystemNoteMetadata.prepend_mod_with('SystemNoteMetadata') diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index eb7d465d585..8aeeae1330c 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -104,3 +104,5 @@ module Terraform end end end + +Terraform::State.prepend_mod diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb index 432ac5b6422..31ff7e4c27d 100644 --- a/app/models/terraform/state_version.rb +++ b/app/models/terraform/state_version.rb @@ -20,4 +20,4 @@ module Terraform end end -Terraform::StateVersion.prepend_if_ee('EE::Terraform::StateVersion') +Terraform::StateVersion.prepend_mod_with('Terraform::StateVersion') diff --git a/app/models/timelog.rb b/app/models/timelog.rb index c1aa84cbbcd..bd543526685 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -3,20 +3,19 @@ class Timelog < ApplicationRecord include Importable + before_save :set_project + validates :time_spent, :user, presence: true validate :issuable_id_is_present, unless: :importing? belongs_to :issue, touch: true belongs_to :merge_request, touch: true + belongs_to :project belongs_to :user belongs_to :note - scope :for_issues_in_group, -> (group) do - joins(:issue).where( - 'EXISTS (?)', - Project.select(1).where(namespace: group.self_and_descendants) - .where('issues.project_id = projects.id') - ) + scope :in_group, -> (group) do + joins(:project).where(projects: { namespace: group.self_and_descendants }) end scope :between_times, -> (start_time, end_time) do @@ -37,6 +36,10 @@ class Timelog < ApplicationRecord end end + def set_project + self.project_id = issuable.project_id + end + # Rails5 defaults to :touch_later, overwrite for normal touch def belongs_to_touch_method :touch diff --git a/app/models/todo.rb b/app/models/todo.rb index c8138587d83..23685fb68e0 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -149,8 +149,8 @@ class Todo < ApplicationRecord .order('todos.created_at') end - def pluck_user_id - pluck(:user_id) + def distinct_user_ids + distinct.pluck(:user_id) end # Count todos grouped by user_id and state, using an UNION query @@ -252,4 +252,4 @@ class Todo < ApplicationRecord end end -Todo.prepend_if_ee('EE::Todo') +Todo.prepend_mod_with('Todo') diff --git a/app/models/upload.rb b/app/models/upload.rb index 46ae924bf8c..0a4acdfc7e3 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -163,4 +163,4 @@ class Upload < ApplicationRecord end end -Upload.prepend_if_ee('EE::Upload') +Upload.prepend_mod_with('Upload') diff --git a/app/models/user.rb b/app/models/user.rb index 507e8cc2cf5..0eb58baae11 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -33,6 +33,8 @@ class User < ApplicationRecord BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval' + COUNT_CACHE_VALIDITY_PERIOD = 24.hours + add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) } add_authentication_token_field :feed_token add_authentication_token_field :static_object_token @@ -94,6 +96,12 @@ class User < ApplicationRecord # Virtual attribute for impersonator attr_accessor :impersonator + attr_writer :max_access_for_group + + def max_access_for_group + @max_access_for_group ||= {} + end + # # Relations # @@ -197,6 +205,7 @@ class User < ApplicationRecord has_one :user_detail has_one :user_highest_role has_one :user_canonical_email + has_one :credit_card_validation, class_name: '::Users::CreditCardValidation' has_one :atlassian_identity, class_name: 'Atlassian::Identity' has_many :reviews, foreign_key: :author_id, inverse_of: :author @@ -309,6 +318,7 @@ class User < ApplicationRecord accepts_nested_attributes_for :user_preference, update_only: true accepts_nested_attributes_for :user_detail, update_only: true + accepts_nested_attributes_for :credit_card_validation, update_only: true state_machine :state, initial: :active do event :block do @@ -316,6 +326,7 @@ class User < ApplicationRecord transition deactivated: :blocked transition ldap_blocked: :blocked transition blocked_pending_approval: :blocked + transition banned: :blocked end event :ldap_block do @@ -328,17 +339,24 @@ class User < ApplicationRecord transition blocked: :active transition ldap_blocked: :active transition blocked_pending_approval: :active + transition banned: :active end event :block_pending_approval do transition active: :blocked_pending_approval end + event :ban do + transition active: :banned + end + event :deactivate do + # Any additional changes to this event should be also + # reflected in app/workers/users/deactivate_dormant_users_worker.rb transition active: :deactivated end - state :blocked, :ldap_blocked, :blocked_pending_approval do + state :blocked, :ldap_blocked, :blocked_pending_approval, :banned do def blocked? true end @@ -365,6 +383,7 @@ class User < ApplicationRecord scope :instance_access_request_approvers_to_be_notified, -> { admins.active.order_recent_sign_in.limit(INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) } scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :blocked_pending_approval, -> { with_states(:blocked_pending_approval) } + scope :banned, -> { with_states(:banned) } scope :external, -> { where(external: true) } scope :non_external, -> { where(external: false) } scope :confirmed, -> { where.not(confirmed_at: nil) } @@ -376,7 +395,7 @@ class User < ApplicationRecord scope :by_name, -> (names) { iwhere(name: Array(names)) } scope :by_user_email, -> (emails) { iwhere(email: Array(emails)) } scope :by_emails, -> (emails) { joins(:emails).where(emails: { email: Array(emails).map(&:downcase) }) } - scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) } + scope :for_todos, -> (todos) { where(id: todos.select(:user_id).distinct) } scope :with_emails, -> { preload(:emails) } scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } scope :with_public_profile, -> { where(private_profile: false) } @@ -416,10 +435,12 @@ class User < ApplicationRecord scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) } scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) } scope :by_id_and_login, ->(id, login) { where(id: id).where('username = LOWER(:login) OR email = LOWER(:login)', login: login) } + scope :dormant, -> { active.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) } + scope :with_no_activity, -> { active.where(last_activity_on: nil) } def preferred_language read_attribute('preferred_language') || - I18n.default_locale.to_s.presence_in(Gitlab::I18n::AVAILABLE_LANGUAGES.keys) || + I18n.default_locale.to_s.presence_in(Gitlab::I18n.available_locales) || 'en' end @@ -584,6 +605,8 @@ class User < ApplicationRecord blocked when 'blocked_pending_approval' blocked_pending_approval + when 'banned' + banned when 'two_factor_disabled' without_two_factor when 'two_factor_enabled' @@ -1098,6 +1121,11 @@ class User < ApplicationRecord Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !password_based_omniauth_user? end + # method overriden in EE + def password_based_login_forbidden? + false + end + def can_change_username? gitlab_config.username_changing_enabled end @@ -1211,6 +1239,10 @@ class User < ApplicationRecord user_highest_role&.highest_access_level || Gitlab::Access::NO_ACCESS end + def credit_card_validated_at + credit_card_validation&.credit_card_validated_at + end + def accessible_deploy_keys DeployKey.from_union([ DeployKey.where(id: project_deploy_keys.select(:deploy_key_id)), @@ -1414,7 +1446,9 @@ class User < ApplicationRecord if namespace_path_errors.include?('has already been taken') && !User.exists?(username: username) self.errors.add(:base, :username_exists_as_a_different_namespace) else - self.errors[:username].concat(namespace_path_errors) + namespace_path_errors.each do |msg| + self.errors.add(:username, msg) + end end end @@ -1619,40 +1653,32 @@ class User < ApplicationRecord @global_notification_setting end - def count_cache_validity_period - if Feature.enabled?(:longer_count_cache_validity, self, default_enabled: :yaml) - 24.hours - else - 20.minutes - end - end - def assigned_open_merge_requests_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: count_cache_validity_period) do + Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count end end def review_requested_open_merge_requests_count(force: false) - Rails.cache.fetch(['users', id, 'review_requested_open_merge_requests_count'], force: force, expires_in: count_cache_validity_period) do + Rails.cache.fetch(['users', id, 'review_requested_open_merge_requests_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do MergeRequestsFinder.new(self, reviewer_id: id, state: 'opened', non_archived: true).execute.count end end def assigned_open_issues_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: count_cache_validity_period) do + Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count end end def todos_done_count(force: false) - Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: count_cache_validity_period) do + Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do TodosFinder.new(self, state: :done).execute.count end end def todos_pending_count(force: false) - Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: count_cache_validity_period) do + Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do TodosFinder.new(self, state: :pending).execute.count end end @@ -1677,6 +1703,12 @@ class User < ApplicationRecord def invalidate_issue_cache_counts Rails.cache.delete(['users', id, 'assigned_open_issues_count']) + + if Feature.enabled?(:assigned_open_issues_cache, default_enabled: :yaml) + run_after_commit do + Users::UpdateOpenIssueCountWorker.perform_async(self.id) + end + end end def invalidate_merge_request_cache_counts @@ -2061,4 +2093,4 @@ class User < ApplicationRecord end end -User.prepend_if_ee('EE::User') +User.prepend_mod_with('User') diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index 0a4db707be6..8fc9efddac9 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -30,7 +30,9 @@ class UserCallout < ApplicationRecord new_user_signups_cap_reached: 26, # EE-only unfinished_tag_cleanup_callout: 27, eoa_bronze_plan_banner: 28, # EE-only - pipeline_needs_banner: 29 + pipeline_needs_banner: 29, + pipeline_needs_hover_tip: 30, + web_ide_ci_environments_guidance: 31 } validates :user, presence: true diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 6b64f583927..458764632ed 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -32,4 +32,4 @@ class UserDetail < ApplicationRecord end end -UserDetail.prepend_if_ee('EE::UserDetail') +UserDetail.prepend_mod_with('UserDetail') diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 0bf8c8f901d..2735e169b5f 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -71,4 +71,4 @@ class UserPreference < ApplicationRecord end end -UserPreference.prepend_if_ee('EE::UserPreference') +UserPreference.prepend_mod_with('UserPreference') diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb new file mode 100644 index 00000000000..5e255acd882 --- /dev/null +++ b/app/models/users/credit_card_validation.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Users + class CreditCardValidation < ApplicationRecord + RELEASE_DAY = Date.new(2021, 5, 17) + + self.table_name = 'user_credit_card_validations' + + belongs_to :user + end +end diff --git a/app/models/users/merge_request_interaction.rb b/app/models/users/merge_request_interaction.rb index 35d1d3206b5..4af9361fbf6 100644 --- a/app/models/users/merge_request_interaction.rb +++ b/app/models/users/merge_request_interaction.rb @@ -41,4 +41,4 @@ module Users end end -::Users::MergeRequestInteraction.prepend_if_ee('EE::Users::MergeRequestInteraction') +::Users::MergeRequestInteraction.prepend_mod_with('Users::MergeRequestInteraction') diff --git a/app/models/users_statistics.rb b/app/models/users_statistics.rb index d724b06a996..a903541f69a 100644 --- a/app/models/users_statistics.rb +++ b/app/models/users_statistics.rb @@ -71,4 +71,4 @@ class UsersStatistics < ApplicationRecord end end -UsersStatistics.prepend_if_ee('EE::UsersStatistics') +UsersStatistics.prepend_mod_with('UsersStatistics') diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb index 7728c9c174e..4e1f48227d9 100644 --- a/app/models/vulnerability.rb +++ b/app/models/vulnerability.rb @@ -17,4 +17,4 @@ class Vulnerability < ApplicationRecord end end -Vulnerability.prepend_ee_mod +Vulnerability.prepend_mod diff --git a/app/models/wiki.rb b/app/models/wiki.rb index 47fe40b0e57..7fc01f373c8 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -88,7 +88,7 @@ class Wiki repository.create_if_not_exists raise CouldNotCreateWikiError unless repository_exists? - rescue => err + rescue StandardError => err Gitlab::ErrorTracking.track_exception(err, wiki: { container_type: container.class.name, container_id: container.id, @@ -192,16 +192,9 @@ class Wiki def delete_page(page, message = nil) return unless page - if Feature.enabled?(:gitaly_replace_wiki_delete_page, user, default_enabled: :yaml) - capture_git_error(:deleted) do - repository.delete_file(user, page.path, **multi_commit_options(:deleted, message, page.title)) + capture_git_error(:deleted) do + repository.delete_file(user, page.path, **multi_commit_options(:deleted, message, page.title)) - after_wiki_activity - - true - end - else - wiki.delete_page(page.path, commit_details(:deleted, message, page.title)) after_wiki_activity true @@ -327,4 +320,4 @@ class Wiki end end -Wiki.prepend_if_ee('EE::Wiki') +Wiki.prepend_mod_with('Wiki') diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 3b9a7ded83e..9ae5a870323 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -127,10 +127,21 @@ class WikiPage @path ||= @page.path end + # Returns a CommitCollection + # + # Queries the commits for current page's path, equivalent to + # `git log path/to/page`. Filters and options supported: + # https://gitlab.com/gitlab-org/gitaly/-/blob/master/proto/commit.proto#L322-344 def versions(options = {}) return [] unless persisted? - wiki.wiki.page_versions(page.path, options) + default_per_page = Kaminari.config.default_per_page + offset = [options[:page].to_i - 1, 0].max * options.fetch(:per_page, default_per_page) + + wiki.repository.commits('HEAD', + path: page.path, + limit: options.fetch(:limit, default_per_page), + offset: offset) end def count_versions diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 1c19751cf0d..0f7a6b852ab 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_dependency 'declarative_policy' - class BasePolicy < DeclarativePolicy::Base desc "User is an instance admin" with_options scope: :user, score: 0 @@ -68,4 +66,4 @@ class BasePolicy < DeclarativePolicy::Base condition(:is_gitlab_com) { ::Gitlab.dev_env_or_com? } end -BasePolicy.prepend_if_ee('EE::BasePolicy') +BasePolicy.prepend_mod_with('BasePolicy') diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 65f2a70672b..6162a31c118 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -21,7 +21,7 @@ module Ci end # overridden in EE - condition(:protected_environment_access) do + condition(:protected_environment) do false end @@ -68,7 +68,10 @@ module Ci rule { project_read_build }.enable :read_build_trace rule { debug_mode & ~project_update_build }.prevent :read_build_trace - rule { ~protected_environment_access & (protected_ref | archived) }.policy do + # Authorizing the user to access to protected entities. + # There is a "jailbreak" mode to exceptionally bypass the authorization, + # however, you should NEVER allow it, rather suspect it's a wrong feature/product design. + rule { ~can?(:jailbreak) & (archived | protected_ref | protected_environment) }.policy do prevent :update_build prevent :update_commit_status prevent :erase_build @@ -108,4 +111,4 @@ module Ci end end -Ci::BuildPolicy.prepend_if_ee('EE::Ci::BuildPolicy') +Ci::BuildPolicy.prepend_mod_with('Ci::BuildPolicy') diff --git a/app/policies/ci/stage_policy.rb b/app/policies/ci/stage_policy.rb new file mode 100644 index 00000000000..1e774df9f58 --- /dev/null +++ b/app/policies/ci/stage_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class StagePolicy < BasePolicy + delegate :pipeline + end +end diff --git a/app/policies/clusters/instance_policy.rb b/app/policies/clusters/instance_policy.rb index d8e8f9ff2c1..3c5ca4bf4e1 100644 --- a/app/policies/clusters/instance_policy.rb +++ b/app/policies/clusters/instance_policy.rb @@ -13,4 +13,4 @@ module Clusters end end -Clusters::InstancePolicy.prepend_if_ee('EE::Clusters::InstancePolicy') +Clusters::InstancePolicy.prepend_mod_with('Clusters::InstancePolicy') diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb index 75849fb10c8..cd19b46ad6c 100644 --- a/app/policies/concerns/policy_actor.rb +++ b/app/policies/concerns/policy_actor.rb @@ -82,4 +82,4 @@ module PolicyActor end end -PolicyActor.prepend_if_ee('EE::PolicyActor') +PolicyActor.prepend_mod_with('PolicyActor') diff --git a/app/policies/concerns/readonly_abilities.rb b/app/policies/concerns/readonly_abilities.rb index 0303d4cff14..300f17088b7 100644 --- a/app/policies/concerns/readonly_abilities.rb +++ b/app/policies/concerns/readonly_abilities.rb @@ -13,6 +13,7 @@ module ReadonlyAbilities create_merge_request_from create_merge_request_in award_emoji + create_incident ].freeze READONLY_FEATURES = %i[ @@ -49,4 +50,4 @@ module ReadonlyAbilities end end -ReadonlyAbilities::ClassMethods.prepend_if_ee('EE::ReadonlyAbilities::ClassMethods') +ReadonlyAbilities::ClassMethods.prepend_mod_with('ReadonlyAbilities::ClassMethods') diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb index f0187a39687..e9e3517b3da 100644 --- a/app/policies/environment_policy.rb +++ b/app/policies/environment_policy.rb @@ -21,4 +21,4 @@ class EnvironmentPolicy < BasePolicy rule { ~stopped }.prevent(:destroy_environment) end -EnvironmentPolicy.prepend_if_ee('EE::EnvironmentPolicy') +EnvironmentPolicy.prepend_mod_with('EnvironmentPolicy') diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index d16c4734b2c..85263ec7c87 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -113,4 +113,4 @@ class GlobalPolicy < BasePolicy rule { external_user }.prevent :create_snippet end -GlobalPolicy.prepend_if_ee('EE::GlobalPolicy') +GlobalPolicy.prepend_mod_with('GlobalPolicy') diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb index 8a4cae232a0..f7a7286aba7 100644 --- a/app/policies/group_member_policy.rb +++ b/app/policies/group_member_policy.rb @@ -30,4 +30,4 @@ class GroupMemberPolicy < BasePolicy end end -GroupMemberPolicy.prepend_if_ee('EE::GroupMemberPolicy') +GroupMemberPolicy.prepend_mod_with('GroupMemberPolicy') diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index fc24525ade7..821fabec266 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -258,4 +258,4 @@ class GroupPolicy < BasePolicy end end -GroupPolicy.prepend_if_ee('EE::GroupPolicy') +GroupPolicy.prepend_mod_with('GroupPolicy') diff --git a/app/policies/identity_provider_policy.rb b/app/policies/identity_provider_policy.rb index 6d6dcaebff8..c539fc64d3f 100644 --- a/app/policies/identity_provider_policy.rb +++ b/app/policies/identity_provider_policy.rb @@ -14,4 +14,4 @@ class IdentityProviderPolicy < BasePolicy rule { protected_provider }.prevent(:unlink) end -IdentityProviderPolicy.prepend_if_ee('EE::IdentityProviderPolicy') +IdentityProviderPolicy.prepend_mod_with('IdentityProviderPolicy') diff --git a/app/policies/service_policy.rb b/app/policies/integration_policy.rb index 61aff444620..c1199d915ea 100644 --- a/app/policies/service_policy.rb +++ b/app/policies/integration_policy.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -class ServicePolicy < BasePolicy +class IntegrationPolicy < BasePolicy delegate(:project) end diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index f49a6ee8498..61263e47d7c 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -28,4 +28,4 @@ class IssuablePolicy < BasePolicy end end -IssuablePolicy.prepend_if_ee('EE::IssuablePolicy') +IssuablePolicy.prepend_mod_with('IssuablePolicy') diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 183f4d8f919..6eec03d6d75 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -41,4 +41,4 @@ class IssuePolicy < IssuablePolicy end end -IssuePolicy.prepend_if_ee('EE::IssuePolicy') +IssuePolicy.prepend_mod_with('IssuePolicy') diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb index e3fb54172f8..e53a916f3ca 100644 --- a/app/policies/merge_request_policy.rb +++ b/app/policies/merge_request_policy.rb @@ -29,4 +29,4 @@ class MergeRequestPolicy < IssuablePolicy end end -MergeRequestPolicy.prepend_if_ee('EE::MergeRequestPolicy') +MergeRequestPolicy.prepend_mod_with('MergeRequestPolicy') diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb index 13eb4a13cac..dcbeda9f5d3 100644 --- a/app/policies/namespace_policy.rb +++ b/app/policies/namespace_policy.rb @@ -23,4 +23,4 @@ class NamespacePolicy < BasePolicy rule { (owner | admin) & can?(:create_projects) }.enable :transfer_projects end -NamespacePolicy.prepend_if_ee('EE::NamespacePolicy') +NamespacePolicy.prepend_mod_with('NamespacePolicy') diff --git a/app/policies/nil_policy.rb b/app/policies/nil_policy.rb deleted file mode 100644 index fc969f8cd05..00000000000 --- a/app/policies/nil_policy.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class NilPolicy < BasePolicy - rule { default }.prevent_all -end diff --git a/app/policies/packages/maven/metadatum_policy.rb b/app/policies/packages/maven/metadatum_policy.rb new file mode 100644 index 00000000000..5dc90209321 --- /dev/null +++ b/app/policies/packages/maven/metadatum_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Maven + class MetadatumPolicy < BasePolicy + delegate { @subject.package } + end + end +end diff --git a/app/policies/packages/nuget/metadatum_policy.rb b/app/policies/packages/nuget/metadatum_policy.rb new file mode 100644 index 00000000000..cdf1283c11a --- /dev/null +++ b/app/policies/packages/nuget/metadatum_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +module Packages + module Nuget + class MetadatumPolicy < BasePolicy + delegate { @subject.package } + end + end +end diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb index ca33b95e523..91f1eb35506 100644 --- a/app/policies/project_member_policy.rb +++ b/app/policies/project_member_policy.rb @@ -8,7 +8,11 @@ class ProjectMemberPolicy < BasePolicy condition(:project_bot) { @subject.user&.project_bot? } rule { anonymous }.prevent_all - rule { target_is_owner }.prevent_all + + rule { target_is_owner }.policy do + prevent :update_project_member + prevent :destroy_project_member + end rule { ~project_bot & can?(:admin_project_member) }.policy do enable :update_project_member diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index c577c8c8471..1ce19511bef 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -171,6 +171,7 @@ class ProjectPolicy < BasePolicy rule { guest | admin }.enable :read_project_for_iids rule { admin }.enable :update_max_artifacts_size + rule { admin }.enable :read_storage_disk_path rule { can?(:read_all_resources) }.enable :read_confidential_issues rule { guest }.enable :guest_access @@ -226,6 +227,8 @@ class ProjectPolicy < BasePolicy enable :read_insights end + rule { can?(:guest_access) & can?(:create_issue) }.enable :create_incident + # These abilities are not allowed to admins that are not members of the project, # that's why they are defined separately. rule { guest & can?(:download_code) }.enable :build_download_code @@ -745,4 +748,4 @@ class ProjectPolicy < BasePolicy end end -ProjectPolicy.prepend_if_ee('EE::ProjectPolicy') +ProjectPolicy.prepend_mod_with('ProjectPolicy') diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index 869f4716298..b8f0be9b4c5 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -51,4 +51,4 @@ class ProjectSnippetPolicy < BasePolicy rule { ~can?(:read_snippet) }.prevent :create_note end -ProjectSnippetPolicy.prepend_if_ee('EE::ProjectSnippetPolicy') +ProjectSnippetPolicy.prepend_mod_with('ProjectSnippetPolicy') diff --git a/app/policies/protected_branch_policy.rb b/app/policies/protected_branch_policy.rb index 1a5c6528b82..8ad06653e5c 100644 --- a/app/policies/protected_branch_policy.rb +++ b/app/policies/protected_branch_policy.rb @@ -10,4 +10,4 @@ class ProtectedBranchPolicy < BasePolicy end end -ProtectedBranchPolicy.prepend_if_ee('EE::ProtectedBranchPolicy') +ProtectedBranchPolicy.prepend_mod_with('ProtectedBranchPolicy') diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 48c2bd3f0bd..067f0f6a9d2 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -33,4 +33,4 @@ class UserPolicy < BasePolicy rule { (user_is_self | admin) & ~blocked }.enable :create_user_personal_access_token end -UserPolicy.prepend_if_ee('EE::UserPolicy') +UserPolicy.prepend_mod_with('UserPolicy') diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb index 1cebf5c561a..c6c6fe837a0 100644 --- a/app/presenters/alert_management/alert_presenter.rb +++ b/app/presenters/alert_management/alert_presenter.rb @@ -112,3 +112,5 @@ module AlertManagement end end end + +AlertManagement::AlertPresenter.prepend_mod_with('AlertManagement::AlertPresenter') diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index cff935d51b5..56dd056b9bc 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true class BlobPresenter < Gitlab::View::Presenter::Delegated + include ApplicationHelper + include BlobHelper + include DiffHelper + include TreeHelper + include ChecksCollaboration + presents :blob def highlight(to: nil, plain: nil) @@ -14,16 +20,68 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated ) end + def plain_data + return if blob.binary? + + highlight(plain: false) + end + def web_url - Gitlab::Routing.url_helpers.project_blob_url(blob.repository.project, File.join(blob.commit_id, blob.path)) + url_helpers.project_blob_url(project, ref_qualified_path) end def web_path - Gitlab::Routing.url_helpers.project_blob_path(blob.repository.project, File.join(blob.commit_id, blob.path)) + url_helpers.project_blob_path(project, ref_qualified_path) + end + + def edit_blob_path + url_helpers.project_edit_blob_path(project, ref_qualified_path) + end + + def raw_path + url_helpers.project_raw_path(project, ref_qualified_path) + end + + def replace_path + url_helpers.project_create_blob_path(project, ref_qualified_path) + end + + def fork_and_edit_path + fork_path_for_current_user(project, edit_blob_path) + end + + def ide_fork_and_edit_path + fork_path_for_current_user(project, ide_edit_path) + end + + def can_modify_blob? + super(blob, project, blob.commit_id) + end + + def ide_edit_path + super(project, blob.commit_id, blob.path) + end + + def external_storage_url + return unless static_objects_external_storage_enabled? + + external_storage_url_or_path(url_helpers.project_raw_url(project, ref_qualified_path)) end private + def url_helpers + Gitlab::Routing.url_helpers + end + + def project + blob.repository.project + end + + def ref_qualified_path + File.join(blob.commit_id, blob.path) + end + def load_all_blob_data blob.load_all_data! if blob.respond_to?(:load_all_data!) end diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index 51a81158f78..384cb3285fc 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -51,4 +51,4 @@ module Ci end end -Ci::BuildPresenter.prepend_if_ee('EE::Ci::BuildPresenter') +Ci::BuildPresenter.prepend_mod_with('Ci::BuildPresenter') diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 6978bc46475..5b233ad89ec 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -138,4 +138,4 @@ module Ci end end -Ci::BuildRunnerPresenter.prepend_if_ee('EE::Ci::BuildRunnerPresenter') +Ci::BuildRunnerPresenter.prepend_mod_with('Ci::BuildRunnerPresenter') diff --git a/app/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter.rb b/app/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter.rb index 2fe3104fe69..d28b4523fd5 100644 --- a/app/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter.rb +++ b/app/presenters/ci/pipeline_artifacts/code_quality_mr_diff_presenter.rb @@ -5,18 +5,20 @@ module Ci class CodeQualityMrDiffPresenter < Gitlab::View::Presenter::Delegated include Gitlab::Utils::StrongMemoize - def for_files(filenames) - quality_files = raw_report["files"].select { |key| filenames.include?(key) } + def for_files(merge_request) + filenames = merge_request.new_paths + mr_diff_report = raw_report(merge_request.id) + quality_files = mr_diff_report["files"]&.select { |key| filenames.include?(key) } { files: quality_files } end private - def raw_report + def raw_report(merge_request_id) strong_memoize(:raw_report) do self.each_blob do |blob| - Gitlab::Json.parse(blob).with_indifferent_access + Gitlab::Json.parse(blob).with_indifferent_access.fetch("merge_request_#{merge_request_id}", {}) end end end diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index a2cdabb912f..82f00f74692 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -8,15 +8,16 @@ module Ci # We use a class method here instead of a constant, allowing EE to redefine # the returned `Hash` more easily. def self.failure_reasons - { unknown_failure: 'Unknown pipeline failure!', - config_error: 'CI/CD YAML configuration error!', - external_validation_failure: 'External pipeline validation failed!', - activity_limit_exceeded: 'Pipeline activity limit exceeded!', - size_limit_exceeded: 'Pipeline size limit exceeded!', - job_activity_limit_exceeded: 'Pipeline job activity limit exceeded!', - deployments_limit_exceeded: 'Pipeline deployments limit exceeded!', - project_deleted: 'The associated project was deleted', - user_blocked: 'The user who created this pipeline is blocked' } + { unknown_failure: 'The reason for the pipeline failure is unknown.', + config_error: 'The pipeline failed due to an error on the CI/CD configuration file.', + external_validation_failure: 'The external pipeline validation failed.', + user_not_verified: 'The pipeline failed due to the user not being verified', + activity_limit_exceeded: 'The pipeline activity limit was exceeded.', + size_limit_exceeded: 'The pipeline size limit was exceeded.', + job_activity_limit_exceeded: 'The pipeline job activity limit was exceeded.', + deployments_limit_exceeded: 'The pipeline deployments limit was exceeded.', + project_deleted: 'The project associated with this pipeline was deleted.', + user_blocked: 'The user who created this pipeline is blocked.' } end presents :pipeline @@ -163,4 +164,4 @@ module Ci end end -Ci::PipelinePresenter.prepend_if_ee('EE::Ci::PipelinePresenter') +Ci::PipelinePresenter.prepend_mod_with('Ci::PipelinePresenter') diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index 62488465c24..a316793dae9 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -86,4 +86,4 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated end end -ClusterablePresenter.prepend_if_ee('EE::ClusterablePresenter') +ClusterablePresenter.prepend_mod_with('ClusterablePresenter') diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index 038fc752255..eb4bd8532af 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -76,7 +76,7 @@ module Clusters def gitlab_managed_apps_logs_path return unless logs_project && can_read_cluster? - if cluster.application_elastic_stack&.available? + if cluster.elastic_stack_adapter&.available? elasticsearch_project_logs_path(logs_project, cluster_id: cluster.id, format: :json) else k8s_project_logs_path(logs_project, cluster_id: cluster.id, format: :json) @@ -144,4 +144,4 @@ module Clusters end end -Clusters::ClusterPresenter.prepend_if_ee('EE::Clusters::ClusterPresenter') +Clusters::ClusterPresenter.prepend_mod_with('Clusters::ClusterPresenter') diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index c8d3457b04a..8ef6e2b7962 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -15,6 +15,7 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated scheduler_failure: 'The scheduler failed to assign job to the runner, please try again or contact system administrator', data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator', forward_deployment_failure: 'The deployment job is older than the previously succeeded deployment job, and therefore cannot be run', + pipeline_loop_detected: 'This job could not be executed because it would create infinitely looping pipelines', invalid_bridge_trigger: 'This job could not be executed because downstream pipeline trigger definition is invalid', downstream_bridge_project_not_found: 'This job could not be executed because downstream bridge project could not be found', insufficient_bridge_permissions: 'This job could not be executed because of insufficient permissions to create a downstream pipeline', @@ -23,7 +24,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated secrets_provider_not_found: 'The secrets provider can not be found', reached_max_descendant_pipelines_depth: 'You reached the maximum depth of child pipelines', project_deleted: 'The job belongs to a deleted project', - user_blocked: 'The user who created this job is blocked' + user_blocked: 'The user who created this job is blocked', + ci_quota_exceeded: 'No more CI minutes available' }.freeze private_constant :CALLOUT_FAILURE_MESSAGES @@ -39,4 +41,4 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated end end -CommitStatusPresenter.prepend_if_ee('::EE::CommitStatusPresenter') +CommitStatusPresenter.prepend_mod_with('CommitStatusPresenter') diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb index dfe8e315f94..adbe20517be 100644 --- a/app/presenters/group_clusterable_presenter.rb +++ b/app/presenters/group_clusterable_presenter.rb @@ -49,4 +49,4 @@ class GroupClusterablePresenter < ClusterablePresenter end end -GroupClusterablePresenter.prepend_if_ee('EE::GroupClusterablePresenter') +GroupClusterablePresenter.prepend_mod_with('GroupClusterablePresenter') diff --git a/app/presenters/group_member_presenter.rb b/app/presenters/group_member_presenter.rb index df51f1eb075..5ab4b51f472 100644 --- a/app/presenters/group_member_presenter.rb +++ b/app/presenters/group_member_presenter.rb @@ -16,4 +16,4 @@ class GroupMemberPresenter < MemberPresenter end end -GroupMemberPresenter.prepend_if_ee('EE::GroupMemberPresenter') +GroupMemberPresenter.prepend_mod_with('GroupMemberPresenter') diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb index 94c1195ed6a..84b3328b37f 100644 --- a/app/presenters/instance_clusterable_presenter.rb +++ b/app/presenters/instance_clusterable_presenter.rb @@ -87,4 +87,4 @@ class InstanceClusterablePresenter < ClusterablePresenter end end -InstanceClusterablePresenter.prepend_if_ee('EE::InstanceClusterablePresenter') +InstanceClusterablePresenter.prepend_mod_with('InstanceClusterablePresenter') diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb index 76bf3bf4577..b7f4ac0555d 100644 --- a/app/presenters/issue_presenter.rb +++ b/app/presenters/issue_presenter.rb @@ -16,4 +16,4 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated end end -IssuePresenter.prepend_if_ee('EE::IssuePresenter') +IssuePresenter.prepend_mod_with('IssuePresenter') diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb index c23d6ce2218..9e51e6fa4ba 100644 --- a/app/presenters/label_presenter.rb +++ b/app/presenters/label_presenter.rb @@ -51,4 +51,4 @@ class LabelPresenter < Gitlab::View::Presenter::Delegated end end -LabelPresenter.prepend_if_ee('EE::LabelPresenter') +LabelPresenter.prepend_mod_with('LabelPresenter') diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb index 0c67fc98ced..b37a43bf251 100644 --- a/app/presenters/member_presenter.rb +++ b/app/presenters/member_presenter.rb @@ -52,4 +52,4 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated end end -MemberPresenter.prepend_if_ee('EE::MemberPresenter') +MemberPresenter.prepend_mod_with('MemberPresenter') diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index a22138011ae..7d0fa9e2f8a 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -151,11 +151,12 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated def assign_to_closing_issues_link # rubocop: disable CodeReuse/ServiceClass - issues = MergeRequests::AssignIssuesService.new(project, - current_user, - merge_request: merge_request, - closes_issues: closing_issues - ).assignable_issues + issues = MergeRequests::AssignIssuesService.new(project: project, + current_user: current_user, + params: { + merge_request: merge_request, + closes_issues: closing_issues + }).assignable_issues path = assign_related_issues_project_merge_request_path(project, merge_request) if issues.present? if issues.count > 1 @@ -273,4 +274,4 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end -MergeRequestPresenter.prepend_if_ee('EE::MergeRequestPresenter') +MergeRequestPresenter.prepend_mod_with('MergeRequestPresenter') diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb index 6640b0c5e94..4fa207b1205 100644 --- a/app/presenters/packages/detail/package_presenter.rb +++ b/app/presenters/packages/detail/package_presenter.rb @@ -17,6 +17,7 @@ module Packages name: name, package_files: @package.package_files.map { |pf| build_package_file_view(pf) }, package_type: @package.package_type, + status: @package.status, project_id: @package.project_id, tags: @package.tags.as_json, updated_at: @package.updated_at, diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb index 718f653eab1..1c5f11ffe59 100644 --- a/app/presenters/project_clusterable_presenter.rb +++ b/app/presenters/project_clusterable_presenter.rb @@ -44,4 +44,4 @@ class ProjectClusterablePresenter < ClusterablePresenter end end -ProjectClusterablePresenter.prepend_if_ee('EE::ProjectClusterablePresenter') +ProjectClusterablePresenter.prepend_mod_with('ProjectClusterablePresenter') diff --git a/app/presenters/project_member_presenter.rb b/app/presenters/project_member_presenter.rb index ff9c3df793a..17947266ed7 100644 --- a/app/presenters/project_member_presenter.rb +++ b/app/presenters/project_member_presenter.rb @@ -16,4 +16,4 @@ class ProjectMemberPresenter < MemberPresenter end end -ProjectMemberPresenter.prepend_if_ee('EE::ProjectMemberPresenter') +ProjectMemberPresenter.prepend_mod_with('ProjectMemberPresenter') diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index aad1c816cf1..4f803ba34f4 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -108,7 +108,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def add_license_ide_path - ide_edit_path(project, default_branch_or_master, 'LICENSE') + ide_edit_path(project, default_branch_or_main, 'LICENSE') end def add_changelog_path @@ -116,7 +116,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def add_changelog_ide_path - ide_edit_path(project, default_branch_or_master, 'CHANGELOG') + ide_edit_path(project, default_branch_or_main, 'CHANGELOG') end def add_contribution_guide_path @@ -124,7 +124,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def add_contribution_guide_ide_path - ide_edit_path(project, default_branch_or_master, 'CONTRIBUTING.md') + ide_edit_path(project, default_branch_or_main, 'CONTRIBUTING.md') end def add_readme_path @@ -132,13 +132,24 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def add_readme_ide_path - ide_edit_path(project, default_branch_or_master, 'README.md') + ide_edit_path(project, default_branch_or_main, 'README.md') end def add_ci_yml_path add_special_file_path(file_name: ci_config_path_or_default) end + def add_code_quality_ci_yml_path + add_special_file_path( + file_name: ci_config_path_or_default, + commit_message: s_("CommitMessage|Add %{file_name} and create a code quality job") % { file_name: ci_config_path_or_default }, + additional_params: { + template: 'Code-Quality', + code_quality_walkthrough: true + } + ) + end + def license_short_name license = repository.license license&.nickname || license&.name || 'LICENSE' @@ -210,7 +221,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated strong_start: '<strong class="project-stat-value">'.html_safe, strong_end: '</strong>'.html_safe }, - empty_repo? ? nil : project_commits_path(project, repository.root_ref)) + empty_repo? ? nil : project_commits_path(project, default_branch_or_main)) end def branches_anchor_data @@ -249,10 +260,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated nil, nil, { - 'target_branch' => default_branch_or_master, - 'original_branch' => default_branch_or_master, + 'target_branch' => default_branch_or_main, + 'original_branch' => default_branch_or_main, 'can_push_code' => 'true', - 'path' => project_create_blob_path(project, default_branch_or_master), + 'path' => project_create_blob_path(project, default_branch_or_main), 'project_path' => project.path } ) @@ -268,7 +279,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def new_file_anchor_data if can_current_user_push_to_default_branch? - new_file_path = empty_repo? ? ide_edit_path(project, default_branch_or_master) : project_new_blob_path(project, default_branch_or_master) + new_file_path = empty_repo? ? ide_edit_path(project, default_branch_or_main) : project_new_blob_path(project, default_branch_or_main) AnchorData.new(false, statistic_icon + _('New file'), @@ -390,16 +401,16 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def topics_to_show - project.tag_list.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord + project.topic_list.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord end def topics_not_shown - project.tag_list - topics_to_show + project.topic_list - topics_to_show end def count_of_extra_topics_not_shown - if project.tag_list.count > MAX_TOPICS_TO_SHOW - project.tag_list.count - MAX_TOPICS_TO_SHOW + if project.topic_list.count > MAX_TOPICS_TO_SHOW + project.topic_list.count - MAX_TOPICS_TO_SHOW else 0 end @@ -468,16 +479,17 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end end - def add_special_file_path(file_name:, commit_message: nil, branch_name: nil) + def add_special_file_path(file_name:, commit_message: nil, branch_name: nil, additional_params: {}) commit_message ||= s_("CommitMessage|Add %{file_name}") % { file_name: file_name } project_new_blob_path( project, - default_branch_or_master, + default_branch_or_main, file_name: file_name, commit_message: commit_message, - branch_name: branch_name + branch_name: branch_name, + **additional_params ) end end -ProjectPresenter.prepend_if_ee('EE::ProjectPresenter') +ProjectPresenter.prepend_mod_with('ProjectPresenter') diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb index b52f3411c49..611294ddfd8 100644 --- a/app/presenters/projects/import_export/project_export_presenter.rb +++ b/app/presenters/projects/import_export/project_export_presenter.rb @@ -31,11 +31,11 @@ module Projects def group_members return [] unless current_user.can?(:admin_group, project.group) - # We need `.where.not(user_id: nil)` here otherwise when a group has an + # We need `.connected_to_user` here otherwise when a group has an # invitee, it would make the following query return 0 rows since a NULL # user_id would be present in the subquery # See http://stackoverflow.com/questions/129077/not-in-clause-and-null-values - non_null_user_ids = project.project_members.where.not(user_id: nil).select(:user_id) + non_null_user_ids = project.project_members.connected_to_user.select(:user_id) GroupMembersFinder.new(project.group).execute.where.not(user_id: non_null_user_ids) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/presenters/service_hook_presenter.rb b/app/presenters/service_hook_presenter.rb index bc20d5b1a3b..8f2ba1a905f 100644 --- a/app/presenters/service_hook_presenter.rb +++ b/app/presenters/service_hook_presenter.rb @@ -4,10 +4,10 @@ class ServiceHookPresenter < Gitlab::View::Presenter::Delegated presents :service_hook def logs_details_path(log) - project_service_hook_log_path(service.project, service, log) + project_service_hook_log_path(integration.project, integration, log) end def logs_retry_path(log) - retry_project_service_hook_log_path(service.project, service, log) + retry_project_service_hook_log_path(integration.project, integration, log) end end diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb index 597ef6ebc39..e9c710e4a0f 100644 --- a/app/presenters/snippet_blob_presenter.rb +++ b/app/presenters/snippet_blob_presenter.rb @@ -9,12 +9,6 @@ class SnippetBlobPresenter < BlobPresenter render_rich_partial end - def plain_data - return if blob.binary? - - highlight(plain: false) - end - def raw_path snippet_blob_raw_route(only_path: true) end diff --git a/app/presenters/terraform/modules_presenter.rb b/app/presenters/terraform/modules_presenter.rb new file mode 100644 index 00000000000..608f69e2019 --- /dev/null +++ b/app/presenters/terraform/modules_presenter.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Terraform + class ModulesPresenter < Gitlab::View::Presenter::Simple + attr_accessor :packages, :system + + presents :modules + + def initialize(packages, system) + @packages = packages + @system = system + end + + def modules + project_url = @packages.first&.project&.web_url + versions = @packages.map do |package| + { + 'version' => package.version, + 'submodules' => [], + 'root' => { + 'dependencies' => [], + 'providers' => [ + { + 'name' => @system, + 'version' => '' + } + ] + } + } + end + + [ + { + 'versions' => versions, + 'source' => project_url + }.compact + ] + end + end +end diff --git a/app/serializers/admin/user_entity.rb b/app/serializers/admin/user_entity.rb index a5cf40a50b9..8fab1fa3a3e 100644 --- a/app/serializers/admin/user_entity.rb +++ b/app/serializers/admin/user_entity.rb @@ -31,4 +31,4 @@ module Admin end end -Admin::UserEntity.prepend_if_ee('EE::Admin::UserEntity') +Admin::UserEntity.prepend_mod_with('Admin::UserEntity') diff --git a/app/serializers/analytics/cycle_analytics/configuration_entity.rb b/app/serializers/analytics/cycle_analytics/configuration_entity.rb new file mode 100644 index 00000000000..45ea7c92758 --- /dev/null +++ b/app/serializers/analytics/cycle_analytics/configuration_entity.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class ConfigurationEntity < Grape::Entity + include RequestAwareEntity + + expose :events, using: Analytics::CycleAnalytics::EventEntity + expose :stages, using: Analytics::CycleAnalytics::StageEntity + + private + + def events + (stage_events.events - stage_events.internal_events).sort_by(&:name) + end + + def stage_events + Gitlab::Analytics::CycleAnalytics::StageEvents + end + end + end +end diff --git a/app/serializers/analytics/cycle_analytics/event_entity.rb b/app/serializers/analytics/cycle_analytics/event_entity.rb new file mode 100644 index 00000000000..b9abf722c8d --- /dev/null +++ b/app/serializers/analytics/cycle_analytics/event_entity.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class EventEntity < Grape::Entity + expose :name + expose :identifier + expose :type + expose :can_be_start_event?, as: :can_be_start_event + expose :allowed_end_events + + private + + def type + object.label_based? ? 'label' : 'simple' + end + + def can_be_start_event? + pairing_rules.has_key?(object) + end + + def allowed_end_events + pairing_rules.fetch(object, []).map do |event| + event.identifier unless stage_events.internal_events.include?(event) + end.compact + end + + def pairing_rules + stage_events.pairing_rules + end + + def stage_events + Gitlab::Analytics::CycleAnalytics::StageEvents + end + end + end +end diff --git a/app/serializers/analytics/cycle_analytics/stage_entity.rb b/app/serializers/analytics/cycle_analytics/stage_entity.rb new file mode 100644 index 00000000000..b24148802d0 --- /dev/null +++ b/app/serializers/analytics/cycle_analytics/stage_entity.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class StageEntity < Grape::Entity + expose :title + expose :hidden + expose :legend + expose :description + expose :id + expose :custom + expose :start_event_identifier, if: -> (s) { s.custom? } + expose :end_event_identifier, if: -> (s) { s.custom? } + expose :start_event_label, using: LabelEntity, if: -> (s) { s.start_event_label_based? } + expose :end_event_label, using: LabelEntity, if: -> (s) { s.end_event_label_based? } + expose :start_event_html_description + expose :end_event_html_description + + def id + object.id || object.name + end + + def start_event_html_description + html_description(object.start_event) + end + + def end_event_html_description + html_description(object.end_event) + end + + private + + def html_description(event) + Banzai::Renderer.render(event.markdown_description, { group: object.group, project: nil }) + end + end + end +end diff --git a/app/serializers/analytics/cycle_analytics/value_stream_entity.rb b/app/serializers/analytics/cycle_analytics/value_stream_entity.rb new file mode 100644 index 00000000000..1943efcc63d --- /dev/null +++ b/app/serializers/analytics/cycle_analytics/value_stream_entity.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class ValueStreamEntity < Grape::Entity + expose :name + expose :id + expose :is_custom do |object| + object.custom? + end + expose :stages, using: Analytics::CycleAnalytics::StageEntity + + private + + def id + object.id || object.name # use the name `default` if the record is not persisted + end + + def stages + object.stages.map { |s| ::Analytics::CycleAnalytics::StagePresenter.new(s) } # rubocop: disable CodeReuse/Presenter + end + end + end +end diff --git a/app/serializers/analytics/cycle_analytics/value_stream_serializer.rb b/app/serializers/analytics/cycle_analytics/value_stream_serializer.rb new file mode 100644 index 00000000000..ffd7aa882e4 --- /dev/null +++ b/app/serializers/analytics/cycle_analytics/value_stream_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class ValueStreamSerializer < BaseSerializer + entity ::Analytics::CycleAnalytics::ValueStreamEntity + end + end +end diff --git a/app/serializers/blob_entity.rb b/app/serializers/blob_entity.rb index a54af899ba2..6dde35a9415 100644 --- a/app/serializers/blob_entity.rb +++ b/app/serializers/blob_entity.rb @@ -16,4 +16,4 @@ class BlobEntity < Grape::Entity end end -BlobEntity.prepend_if_ee('EE::BlobEntity') +BlobEntity.prepend_mod_with('BlobEntity') diff --git a/app/serializers/board_simple_entity.rb b/app/serializers/board_simple_entity.rb index a3c16a0b5c7..ab625490966 100644 --- a/app/serializers/board_simple_entity.rb +++ b/app/serializers/board_simple_entity.rb @@ -5,4 +5,4 @@ class BoardSimpleEntity < Grape::Entity expose :name end -BoardSimpleEntity.prepend_if_ee('EE::BoardSimpleEntity') +BoardSimpleEntity.prepend_mod_with('BoardSimpleEntity') diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 0ddcad4dcb9..6fbd14f523d 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -155,4 +155,4 @@ class BuildDetailsEntity < JobEntity end end -BuildDetailsEntity.prepend_if_ee('::EE::BuildDetailEntity') +BuildDetailsEntity.prepend_mod_with('BuildDetailEntity') diff --git a/app/serializers/ci/downloadable_artifact_entity.rb b/app/serializers/ci/downloadable_artifact_entity.rb new file mode 100644 index 00000000000..1f3885f0715 --- /dev/null +++ b/app/serializers/ci/downloadable_artifact_entity.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Ci + class DownloadableArtifactEntity < Grape::Entity + include RequestAwareEntity + + expose :artifacts do |pipeline, options| + artifacts = pipeline.downloadable_artifacts + + if Feature.enabled?(:non_public_artifacts) + artifacts = artifacts.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact.job) } + end + + BuildArtifactEntity.represent(artifacts, options.merge(project: pipeline.project)) + end + end +end diff --git a/app/serializers/ci/downloadable_artifact_serializer.rb b/app/serializers/ci/downloadable_artifact_serializer.rb new file mode 100644 index 00000000000..fc4c9fa558e --- /dev/null +++ b/app/serializers/ci/downloadable_artifact_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Ci + class DownloadableArtifactSerializer < BaseSerializer + entity DownloadableArtifactEntity + end +end diff --git a/app/serializers/ci/pipeline_entity.rb b/app/serializers/ci/pipeline_entity.rb index 743643a978f..fa0e904fbde 100644 --- a/app/serializers/ci/pipeline_entity.rb +++ b/app/serializers/ci/pipeline_entity.rb @@ -121,4 +121,4 @@ class Ci::PipelineEntity < Grape::Entity end end -Ci::PipelineEntity.prepend_if_ee('EE::Ci::PipelineEntity') +Ci::PipelineEntity.prepend_mod_with('Ci::PipelineEntity') diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb index b904666971e..ba42e14be22 100644 --- a/app/serializers/cluster_entity.rb +++ b/app/serializers/cluster_entity.rb @@ -28,6 +28,6 @@ class ClusterEntity < Grape::Entity end expose :enable_advanced_logs_querying do |cluster| - cluster.application_elastic_stack_available? + cluster.elastic_stack_available? end end diff --git a/app/serializers/context_commits_diff_entity.rb b/app/serializers/context_commits_diff_entity.rb new file mode 100644 index 00000000000..89ebf6a4815 --- /dev/null +++ b/app/serializers/context_commits_diff_entity.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class ContextCommitsDiffEntity < Grape::Entity + include Gitlab::Routing + + expose :commits_count + + expose :showing_context_commits_diff do |_, options| + options[:only_context_commits] + end + + expose :diffs_path do |diff| + merge_request = diff.merge_request + project = merge_request.target_project + + next unless project + + diffs_project_merge_request_path(project, merge_request, only_context_commits: true) + end +end diff --git a/app/serializers/current_board_entity.rb b/app/serializers/current_board_entity.rb index 08f31bc698f..530f7f5dea3 100644 --- a/app/serializers/current_board_entity.rb +++ b/app/serializers/current_board_entity.rb @@ -7,4 +7,4 @@ class CurrentBoardEntity < Grape::Entity expose :hide_closed_list end -CurrentBoardEntity.prepend_if_ee('EE::CurrentBoardEntity') +CurrentBoardEntity.prepend_mod_with('CurrentBoardEntity') diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index a37011d0100..08a939e86c5 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -38,7 +38,7 @@ class DeploymentEntity < Grape::Entity expose :commit, using: CommitEntity, if: -> (*) { include_details? } expose :manual_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } expose :scheduled_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? } - expose :playable_build, expose_nil: false, if: -> (*) { include_details? && can_create_deployment? } do |deployment, options| + expose :playable_build, if: -> (deployment) { include_details? && can_create_deployment? && deployment.playable_build } do |deployment, options| JobEntity.represent(deployment.playable_build, options.merge(only: [:play_path, :retry_path])) end diff --git a/app/serializers/diffs_entity.rb b/app/serializers/diffs_entity.rb index 4bc6644a5cb..64f7f8bb5eb 100644 --- a/app/serializers/diffs_entity.rb +++ b/app/serializers/diffs_entity.rb @@ -84,6 +84,15 @@ class DiffsEntity < Grape::Entity project_blob_path(merge_request.project, merge_request.diff_head_sha) end + expose :context_commits_diff, if: -> (_) { merge_request&.project&.context_commits_enabled? } do |diffs, options| + next unless merge_request.context_commits_diff.commits_count > 0 + + ContextCommitsDiffEntity.represent( + merge_request.context_commits_diff, + options + ) + end + def merge_request options[:merge_request] end diff --git a/app/serializers/discussion_serializer.rb b/app/serializers/discussion_serializer.rb index 7e7148b046e..8bae5687a64 100644 --- a/app/serializers/discussion_serializer.rb +++ b/app/serializers/discussion_serializer.rb @@ -18,4 +18,4 @@ class DiscussionSerializer < BaseSerializer end end -DiscussionSerializer.prepend_if_ee('EE::DiscussionSerializer') +DiscussionSerializer.prepend_mod_with('DiscussionSerializer') diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 66ca2382901..6105b52fbda 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -21,7 +21,7 @@ class EnvironmentEntity < Grape::Entity expose :stop_action_available?, as: :has_stop_action expose :rollout_status, if: -> (*) { can_read_deploy_board? }, using: RolloutStatusEntity - expose :upcoming_deployment, expose_nil: false do |environment, ops| + expose :upcoming_deployment, if: -> (environment) { environment.upcoming_deployment } do |environment, ops| DeploymentEntity.represent(environment.upcoming_deployment, ops.merge(except: UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT)) end @@ -122,4 +122,4 @@ class EnvironmentEntity < Grape::Entity end end -EnvironmentEntity.prepend_if_ee('::EE::EnvironmentEntity') +EnvironmentEntity.prepend_mod_with('EnvironmentEntity') diff --git a/app/serializers/environment_serializer.rb b/app/serializers/environment_serializer.rb index 2bb9a7e7254..2fb1ad52135 100644 --- a/app/serializers/environment_serializer.rb +++ b/app/serializers/environment_serializer.rb @@ -23,6 +23,8 @@ class EnvironmentSerializer < BaseSerializer latest: super(item.latest, opts) } end else + resource = @paginator.paginate(resource) if paginated? + super(batch_load(resource), opts) end end @@ -52,7 +54,7 @@ class EnvironmentSerializer < BaseSerializer def batch_load(resource) resource = resource.preload(environment_associations) - resource.all.tap do |environments| + resource.all.to_a.tap do |environments| environments.each do |environment| # Batch loading the commits of the deployments environment.last_deployment&.commit&.try(:lazy_author) @@ -96,4 +98,4 @@ class EnvironmentSerializer < BaseSerializer # rubocop: enable CodeReuse/ActiveRecord end -EnvironmentSerializer.prepend_if_ee('EE::EnvironmentSerializer') +EnvironmentSerializer.prepend_mod_with('EnvironmentSerializer') diff --git a/app/serializers/evidences/release_entity.rb b/app/serializers/evidences/release_entity.rb index dfc4f52de07..3b16347d7c0 100644 --- a/app/serializers/evidences/release_entity.rb +++ b/app/serializers/evidences/release_entity.rb @@ -12,4 +12,4 @@ module Evidences end end -Evidences::ReleaseEntity.prepend_if_ee('EE::Evidences::ReleaseEntity') +Evidences::ReleaseEntity.prepend_mod_with('Evidences::ReleaseEntity') diff --git a/app/serializers/fork_namespace_entity.rb b/app/serializers/fork_namespace_entity.rb index fc238fa3958..fbcdf91a1af 100644 --- a/app/serializers/fork_namespace_entity.rb +++ b/app/serializers/fork_namespace_entity.rb @@ -49,4 +49,4 @@ class ForkNamespaceEntity < Grape::Entity end end -ForkNamespaceEntity.prepend_if_ee('EE::ForkNamespaceEntity') +ForkNamespaceEntity.prepend_mod_with('ForkNamespaceEntity') diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb index adbda790dee..619ca0b5f82 100644 --- a/app/serializers/group_child_entity.rb +++ b/app/serializers/group_child_entity.rb @@ -100,4 +100,4 @@ class GroupChildEntity < Grape::Entity end end -GroupChildEntity.prepend_if_ee('EE::GroupChildEntity') +GroupChildEntity.prepend_mod_with('GroupChildEntity') diff --git a/app/serializers/group_issuable_autocomplete_entity.rb b/app/serializers/group_issuable_autocomplete_entity.rb new file mode 100644 index 00000000000..f950a7db785 --- /dev/null +++ b/app/serializers/group_issuable_autocomplete_entity.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class GroupIssuableAutocompleteEntity < Grape::Entity + expose :iid + expose :title + expose :reference do |issuable, options| + issuable.to_reference(options[:parent_group]) + end +end diff --git a/app/serializers/group_issuable_autocomplete_serializer.rb b/app/serializers/group_issuable_autocomplete_serializer.rb new file mode 100644 index 00000000000..59e9201d405 --- /dev/null +++ b/app/serializers/group_issuable_autocomplete_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class GroupIssuableAutocompleteSerializer < BaseSerializer + entity GroupIssuableAutocompleteEntity +end diff --git a/app/serializers/issuable_sidebar_basic_entity.rb b/app/serializers/issuable_sidebar_basic_entity.rb index 4b3d6f21d6d..14e416fb71a 100644 --- a/app/serializers/issuable_sidebar_basic_entity.rb +++ b/app/serializers/issuable_sidebar_basic_entity.rb @@ -118,4 +118,4 @@ class IssuableSidebarBasicEntity < Grape::Entity end end -IssuableSidebarBasicEntity.prepend_if_ee('EE::IssuableSidebarBasicEntity') +IssuableSidebarBasicEntity.prepend_mod_with('IssuableSidebarBasicEntity') diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb index ea629d9d774..17a36f5fb07 100644 --- a/app/serializers/issue_board_entity.rb +++ b/app/serializers/issue_board_entity.rb @@ -20,7 +20,7 @@ class IssueBoardEntity < Grape::Entity API::Entities::Project.represent issue.project, only: [:id, :path] end - expose :milestone, expose_nil: false do |issue| + expose :milestone, if: -> (issue) { issue.milestone } do |issue| API::Entities::Milestone.represent issue.milestone, only: [:id, :title] end @@ -53,4 +53,4 @@ class IssueBoardEntity < Grape::Entity end end -IssueBoardEntity.prepend_if_ee('EE::IssueBoardEntity') +IssueBoardEntity.prepend_mod_with('IssueBoardEntity') diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 647a73495f8..773bbf268eb 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -77,4 +77,4 @@ class IssueEntity < IssuableEntity end end -IssueEntity.prepend_if_ee('::EE::IssueEntity') +IssueEntity.prepend_mod_with('IssueEntity') diff --git a/app/serializers/issue_sidebar_basic_entity.rb b/app/serializers/issue_sidebar_basic_entity.rb index e9e05718af9..f93a42e5f98 100644 --- a/app/serializers/issue_sidebar_basic_entity.rb +++ b/app/serializers/issue_sidebar_basic_entity.rb @@ -6,4 +6,4 @@ class IssueSidebarBasicEntity < IssuableSidebarBasicEntity expose :severity end -IssueSidebarBasicEntity.prepend_if_ee('EE::IssueSidebarBasicEntity') +IssueSidebarBasicEntity.prepend_mod_with('IssueSidebarBasicEntity') diff --git a/app/serializers/issue_sidebar_extras_entity.rb b/app/serializers/issue_sidebar_extras_entity.rb index ea2a49baa41..6fd1045882b 100644 --- a/app/serializers/issue_sidebar_extras_entity.rb +++ b/app/serializers/issue_sidebar_extras_entity.rb @@ -3,4 +3,4 @@ class IssueSidebarExtrasEntity < IssuableSidebarExtrasEntity end -IssueSidebarExtrasEntity.prepend_if_ee('EE::IssueSidebarExtrasEntity') +IssueSidebarExtrasEntity.prepend_mod_with('IssueSidebarExtrasEntity') diff --git a/app/serializers/job_entity.rb b/app/serializers/job_entity.rb index d05b500b140..eb8622edb38 100644 --- a/app/serializers/job_entity.rb +++ b/app/serializers/job_entity.rb @@ -7,6 +7,7 @@ class JobEntity < Grape::Entity expose :name expose :started?, as: :started + expose :complete?, as: :complete expose :archived?, as: :archived # bridge jobs don't have build detail pages diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb index 6cbdaeea5ea..7559a03bd3b 100644 --- a/app/serializers/member_entity.rb +++ b/app/serializers/member_entity.rb @@ -57,4 +57,4 @@ class MemberEntity < Grape::Entity end end -MemberEntity.prepend_if_ee('EE::MemberEntity') +MemberEntity.prepend_mod_with('MemberEntity') diff --git a/app/serializers/member_user_entity.rb b/app/serializers/member_user_entity.rb index a022966c041..01920fc95bb 100644 --- a/app/serializers/member_user_entity.rb +++ b/app/serializers/member_user_entity.rb @@ -25,4 +25,4 @@ class MemberUserEntity < UserEntity end end -MemberUserEntity.prepend_if_ee('EE::MemberUserEntity') +MemberUserEntity.prepend_mod_with('MemberUserEntity') diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb index 52f5b975656..6ac43e02f3c 100644 --- a/app/serializers/merge_request_poll_cached_widget_entity.rb +++ b/app/serializers/merge_request_poll_cached_widget_entity.rb @@ -187,4 +187,4 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity end end -MergeRequestPollCachedWidgetEntity.prepend_if_ee('EE::MergeRequestPollCachedWidgetEntity') +MergeRequestPollCachedWidgetEntity.prepend_mod_with('MergeRequestPollCachedWidgetEntity') diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index 97a81d8170f..c00dceadf22 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -158,4 +158,4 @@ class MergeRequestPollWidgetEntity < Grape::Entity end end -MergeRequestPollWidgetEntity.prepend_if_ee('EE::MergeRequestPollWidgetEntity') +MergeRequestPollWidgetEntity.prepend_mod_with('MergeRequestPollWidgetEntity') diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index 508a2510dbd..e8fc18e6cf3 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -28,4 +28,4 @@ class MergeRequestSerializer < BaseSerializer end end -MergeRequestSerializer.prepend_if_ee('EE::MergeRequestSerializer') +MergeRequestSerializer.prepend_mod_with('MergeRequestSerializer') diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb index a36c4da3e83..66672494bd9 100644 --- a/app/serializers/merge_request_user_entity.rb +++ b/app/serializers/merge_request_user_entity.rb @@ -29,4 +29,4 @@ class MergeRequestUserEntity < ::API::Entities::UserBasic end end -MergeRequestUserEntity.prepend_if_ee('EE::MergeRequestUserEntity') +MergeRequestUserEntity.prepend_mod_with('MergeRequestUserEntity') diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index a168c7a8490..ac9970579ed 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -52,7 +52,7 @@ class MergeRequestWidgetEntity < Grape::Entity end expose :reviewing_and_managing_merge_requests_docs_path do |merge_request| - help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: "checkout-merge-requests-locally-through-the-head-ref") + help_page_path('user/project/merge_requests/reviews/index.md', anchor: "checkout-merge-requests-locally-through-the-head-ref") end expose :merge_request_pipelines_docs_path do |merge_request| @@ -176,4 +176,4 @@ class MergeRequestWidgetEntity < Grape::Entity end end -MergeRequestWidgetEntity.prepend_if_ee('EE::MergeRequestWidgetEntity') +MergeRequestWidgetEntity.prepend_mod_with('MergeRequestWidgetEntity') diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index d44958bc0c4..8308e954c06 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -103,4 +103,4 @@ class NoteEntity < API::Entities::Note end end -NoteEntity.prepend_if_ee('EE::NoteEntity') +NoteEntity.prepend_mod_with('NoteEntity') diff --git a/app/serializers/note_user_entity.rb b/app/serializers/note_user_entity.rb index 38e71528f18..c3f14fb0f9e 100644 --- a/app/serializers/note_user_entity.rb +++ b/app/serializers/note_user_entity.rb @@ -4,4 +4,4 @@ class NoteUserEntity < UserEntity unexpose :web_url end -NoteUserEntity.prepend_if_ee('EE::NoteUserEntity') +NoteUserEntity.prepend_mod_with('NoteUserEntity') diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb index bb6aa2f78ac..f459e700c03 100644 --- a/app/serializers/pipeline_details_entity.rb +++ b/app/serializers/pipeline_details_entity.rb @@ -8,17 +8,13 @@ class PipelineDetailsEntity < Ci::PipelineEntity end expose :details do - expose :artifacts do |pipeline, options| - rel = pipeline.downloadable_artifacts - - if Feature.enabled?(:non_public_artifacts, type: :development) - rel = rel.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact.job) } - end - - BuildArtifactEntity.represent(rel, options.merge(project: pipeline.project)) - end expose :manual_actions, using: BuildActionEntity expose :scheduled_actions, using: BuildActionEntity + expose :code_quality_build_path, if: -> (_, options) { options[:code_quality_walkthrough] } do |pipeline| + next unless code_quality_build = pipeline.builds.finished.find_by_name('code_quality') + + project_job_path(pipeline.project, code_quality_build, code_quality_walkthrough: true) + end end expose :triggered_by_pipeline, as: :triggered_by, with: TriggeredPipelineEntity diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index 9a2e29a6ee3..9cfc81e8705 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -49,10 +49,6 @@ class PipelineSerializer < BaseSerializer { manual_actions: :metadata, scheduled_actions: :metadata, - downloadable_artifacts: { - project: [:route, { namespace: :route }], - job: [] - }, failed_builds: %i(project metadata), merge_request: { source_project: [:route, { namespace: :route }], @@ -74,4 +70,4 @@ class PipelineSerializer < BaseSerializer end end -PipelineSerializer.prepend_if_ee('EE::PipelineSerializer') +PipelineSerializer.prepend_mod_with('PipelineSerializer') diff --git a/app/serializers/project_mirror_entity.rb b/app/serializers/project_mirror_entity.rb index daea209deb4..215f659caba 100644 --- a/app/serializers/project_mirror_entity.rb +++ b/app/serializers/project_mirror_entity.rb @@ -8,4 +8,4 @@ class ProjectMirrorEntity < Grape::Entity end end -ProjectMirrorEntity.prepend_if_ee('::EE::ProjectMirrorEntity') +ProjectMirrorEntity.prepend_mod_with('ProjectMirrorEntity') diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb index 299160cd1bf..0e64b843fd3 100644 --- a/app/serializers/test_case_entity.rb +++ b/app/serializers/test_case_entity.rb @@ -20,6 +20,6 @@ class TestCaseEntity < Grape::Entity alias_method :test_case, :object def can_read_screenshots? - Feature.enabled?(:junit_pipeline_screenshots_view, options[:project]) && test_case.has_attachment? + test_case.has_attachment? end end diff --git a/app/serializers/user_entity.rb b/app/serializers/user_entity.rb index 9386c06b87a..1a2778cbf30 100644 --- a/app/serializers/user_entity.rb +++ b/app/serializers/user_entity.rb @@ -3,4 +3,4 @@ class UserEntity < API::Entities::UserPath end -UserEntity.prepend_if_ee('EE::UserEntity') +UserEntity.prepend_mod_with('UserEntity') diff --git a/app/serializers/user_preference_entity.rb b/app/serializers/user_preference_entity.rb index 4a5dda1e364..24c5bccdeb9 100644 --- a/app/serializers/user_preference_entity.rb +++ b/app/serializers/user_preference_entity.rb @@ -13,4 +13,4 @@ class UserPreferenceEntity < Grape::Entity end end -UserPreferenceEntity.prepend_if_ee('EE::UserPreferenceEntity') +UserPreferenceEntity.prepend_mod_with('UserPreferenceEntity') diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index dfbd787298d..5e7dab31e8a 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -21,4 +21,4 @@ class UserSerializer < BaseSerializer end end -UserSerializer.prepend_if_ee('EE::UserSerializer') +UserSerializer.prepend_mod_with('UserSerializer') diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb index 253c3a84fef..f7a4bf1a9f9 100644 --- a/app/services/admin/propagate_integration_service.rb +++ b/app/services/admin/propagate_integration_service.rb @@ -5,7 +5,7 @@ module Admin include PropagateService def propagate - if integration.instance? + if integration.instance_level? update_inherited_integrations create_integration_for_groups_without_integration create_integration_for_projects_without_integration @@ -20,14 +20,14 @@ module Admin def update_inherited_integrations propagate_integrations( - Service.by_type(integration.type).inherit_from_id(integration.id), + Integration.by_type(integration.type).inherit_from_id(integration.id), PropagateIntegrationInheritWorker ) end def update_inherited_descendant_integrations propagate_integrations( - Service.inherited_descendants_from_self_or_ancestors_from(integration), + Integration.inherited_descendants_from_self_or_ancestors_from(integration), PropagateIntegrationInheritDescendantWorker ) end diff --git a/app/services/alert_management/http_integrations/create_service.rb b/app/services/alert_management/http_integrations/create_service.rb index e7f1084ce5c..1abe0548c45 100644 --- a/app/services/alert_management/http_integrations/create_service.rb +++ b/app/services/alert_management/http_integrations/create_service.rb @@ -66,4 +66,4 @@ module AlertManagement end end -::AlertManagement::HttpIntegrations::CreateService.prepend_if_ee('::EE::AlertManagement::HttpIntegrations::CreateService') +::AlertManagement::HttpIntegrations::CreateService.prepend_mod_with('AlertManagement::HttpIntegrations::CreateService') diff --git a/app/services/alert_management/http_integrations/update_service.rb b/app/services/alert_management/http_integrations/update_service.rb index af079f670b8..8662f966a2e 100644 --- a/app/services/alert_management/http_integrations/update_service.rb +++ b/app/services/alert_management/http_integrations/update_service.rb @@ -56,4 +56,4 @@ module AlertManagement end end -::AlertManagement::HttpIntegrations::UpdateService.prepend_if_ee('::EE::AlertManagement::HttpIntegrations::UpdateService') +::AlertManagement::HttpIntegrations::UpdateService.prepend_mod_with('AlertManagement::HttpIntegrations::UpdateService') diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb index 0591376bcdf..605ab7a1869 100644 --- a/app/services/alert_management/process_prometheus_alert_service.rb +++ b/app/services/alert_management/process_prometheus_alert_service.rb @@ -25,13 +25,6 @@ module AlertManagement attr_reader :project, :payload - override :process_new_alert - def process_new_alert - return if resolving_alert? - - super - end - override :incoming_payload def incoming_payload strong_memoize(:incoming_payload) do diff --git a/app/services/analytics/cycle_analytics/stages/base_service.rb b/app/services/analytics/cycle_analytics/stages/base_service.rb new file mode 100644 index 00000000000..b676eff0a0b --- /dev/null +++ b/app/services/analytics/cycle_analytics/stages/base_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + module Stages + class BaseService + include Gitlab::Allowable + + DEFAULT_VALUE_STREAM_NAME = 'default' + + def initialize(parent:, current_user:, params: {}) + @parent = parent + @current_user = current_user + @params = params + end + + def execute + raise NotImplementedError + end + + private + + attr_reader :parent, :current_user, :params + + def success(stage, http_status = :created) + ServiceResponse.success(payload: { stage: stage }, http_status: http_status) + end + + def forbidden + ServiceResponse.error(message: 'Forbidden', payload: {}, http_status: :forbidden) + end + + def build_default_stages + Gitlab::Analytics::CycleAnalytics::DefaultStages.all.map do |stage_params| + parent.cycle_analytics_stages.build(stage_params.merge(value_stream: value_stream)) + end + end + + def value_stream + @value_stream ||= params[:value_stream] + end + end + end + end +end + +Analytics::CycleAnalytics::Stages::BaseService.prepend_mod_with('Analytics::CycleAnalytics::Stages::BaseService') diff --git a/app/services/analytics/cycle_analytics/stages/list_service.rb b/app/services/analytics/cycle_analytics/stages/list_service.rb new file mode 100644 index 00000000000..a6b94ef8295 --- /dev/null +++ b/app/services/analytics/cycle_analytics/stages/list_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + module Stages + class ListService < Analytics::CycleAnalytics::Stages::BaseService + def execute + return forbidden unless allowed? + + success(build_default_stages) + end + + private + + def allowed? + can?(current_user, :read_cycle_analytics, parent) + end + + def success(stages) + ServiceResponse.success(payload: { stages: stages }) + end + end + end + end +end + +Analytics::CycleAnalytics::Stages::ListService.prepend_mod_with('Analytics::CycleAnalytics::Stages::ListService') diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index 7792b811b4e..7728982779e 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -120,4 +120,4 @@ module ApplicationSettings end end -ApplicationSettings::UpdateService.prepend_if_ee('EE::ApplicationSettings::UpdateService') +ApplicationSettings::UpdateService.prepend_mod_with('ApplicationSettings::UpdateService') diff --git a/app/services/applications/create_service.rb b/app/services/applications/create_service.rb index 92500fbc254..96cde9057c7 100644 --- a/app/services/applications/create_service.rb +++ b/app/services/applications/create_service.rb @@ -25,4 +25,4 @@ module Applications end end -Applications::CreateService.prepend_if_ee('EE::Applications::CreateService') +Applications::CreateService.prepend_mod_with('Applications::CreateService') diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb index d1558c60c3d..60421f61007 100644 --- a/app/services/audit_event_service.rb +++ b/app/services/audit_event_service.rb @@ -131,9 +131,9 @@ class AuditEventService def save_or_track(event) event.save! - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, audit_event_type: event.class.to_s) end end -AuditEventService.prepend_if_ee('EE::AuditEventService') +AuditEventService.prepend_mod_with('AuditEventService') diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb index d74f20511bd..5fde346c4ab 100644 --- a/app/services/auth/container_registry_authentication_service.rb +++ b/app/services/auth/container_registry_authentication_service.rb @@ -251,4 +251,4 @@ module Auth end end -Auth::ContainerRegistryAuthenticationService.prepend_if_ee('EE::Auth::ContainerRegistryAuthenticationService') +Auth::ContainerRegistryAuthenticationService.prepend_mod_with('Auth::ContainerRegistryAuthenticationService') diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index 41236286d23..142eebca2e3 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -15,7 +15,7 @@ module AutoMerge AutoMergeProcessWorker.perform_async(merge_request.id) strategy.to_sym - rescue => e + rescue StandardError => e track_exception(e, merge_request) :failed end @@ -35,7 +35,7 @@ module AutoMerge end success - rescue => e + rescue StandardError => e track_exception(e, merge_request) error("Can't cancel the automatic merge", 406) end @@ -47,7 +47,7 @@ module AutoMerge end success - rescue => e + rescue StandardError => e track_exception(e, merge_request) error("Can't abort the automatic merge", 406) end diff --git a/app/services/auto_merge_service.rb b/app/services/auto_merge_service.rb index c5cbcc7c93b..912248d3a06 100644 --- a/app/services/auto_merge_service.rb +++ b/app/services/auto_merge_service.rb @@ -74,4 +74,4 @@ class AutoMergeService < BaseService end end -AutoMergeService.prepend_if_ee('EE::AutoMergeService') +AutoMergeService.prepend_mod_with('AutoMergeService') diff --git a/app/services/award_emojis/add_service.rb b/app/services/award_emojis/add_service.rb index ceb7a38cead..f45a4330c09 100644 --- a/app/services/award_emojis/add_service.rb +++ b/app/services/award_emojis/add_service.rb @@ -46,4 +46,4 @@ module AwardEmojis end end -AwardEmojis::AddService.prepend_if_ee('EE::AwardEmojis::AddService') +AwardEmojis::AddService.prepend_mod_with('AwardEmojis::AddService') diff --git a/app/services/award_emojis/destroy_service.rb b/app/services/award_emojis/destroy_service.rb index cfd194262f9..47dc8418e07 100644 --- a/app/services/award_emojis/destroy_service.rb +++ b/app/services/award_emojis/destroy_service.rb @@ -26,4 +26,4 @@ module AwardEmojis end end -AwardEmojis::DestroyService.prepend_if_ee('EE::AwardEmojis::DestroyService') +AwardEmojis::DestroyService.prepend_mod_with('AwardEmojis::DestroyService') diff --git a/app/services/base_container_service.rb b/app/services/base_container_service.rb index 6852237dc25..ee15763ce65 100644 --- a/app/services/base_container_service.rb +++ b/app/services/base_container_service.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true -# Base class, scoped by container (project or group) +# Base class, scoped by container (project or group). +# +# New or existing services which only require project as a container +# should subclass BaseProjectService. +# +# If you require a different but specific, non-polymorphic container (such +# as group), consider creating a new subclass such as BaseGroupService, +# and update the related comment at the top of the original BaseService. class BaseContainerService include BaseServiceUtility diff --git a/app/services/base_count_service.rb b/app/services/base_count_service.rb index 2936bdae16e..c316c488148 100644 --- a/app/services/base_count_service.rb +++ b/app/services/base_count_service.rb @@ -49,4 +49,4 @@ class BaseCountService end end -BaseCountService.prepend_if_ee('EE::BaseCountService') +BaseCountService.prepend_mod_with('BaseCountService') diff --git a/app/services/base_project_service.rb b/app/services/base_project_service.rb new file mode 100644 index 00000000000..fb466e61673 --- /dev/null +++ b/app/services/base_project_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Base class, scoped by project +class BaseProjectService < ::BaseContainerService + attr_accessor :project + + def initialize(project:, current_user: nil, params: {}) + super(container: project, current_user: current_user, params: params) + + @project = project + end + + delegate :repository, to: :project +end diff --git a/app/services/base_service.rb b/app/services/base_service.rb index 20dfeb67815..7ab87a1af09 100644 --- a/app/services/base_service.rb +++ b/app/services/base_service.rb @@ -6,9 +6,12 @@ # and existing service will use these one by one. # After all are migrated, we can remove this class. # -# TODO: New services should consider inheriting from -# BaseContainerService, or create new base class: -# https://gitlab.com/gitlab-org/gitlab/-/issues/216672 +# New services should consider inheriting from: +# +# - BaseContainerService for services scoped by container (project or group) +# - BaseProjectService for services scoped to projects +# +# or, create a new base class and update this comment. class BaseService include BaseServiceUtility diff --git a/app/services/boards/base_items_list_service.rb b/app/services/boards/base_items_list_service.rb index 5aebf216460..cbc7a332cbe 100644 --- a/app/services/boards/base_items_list_service.rb +++ b/app/services/boards/base_items_list_service.rb @@ -129,7 +129,7 @@ module Boards # rubocop: disable CodeReuse/ActiveRecord def label_links(label_ids) LabelLink - .where('label_links.target_type = ?', item_model) + .where(label_links: { target_type: item_model }) .where(item_model.arel_table[:id].eq(LabelLink.arel_table[:target_id]).to_sql) .where(label_id: label_ids) end diff --git a/app/services/boards/base_service.rb b/app/services/boards/base_service.rb index 83bb69b3822..f371f88d44b 100644 --- a/app/services/boards/base_service.rb +++ b/app/services/boards/base_service.rb @@ -13,4 +13,4 @@ module Boards end end -Boards::BaseService.prepend_if_ee('EE::Boards::BaseService') +Boards::BaseService.prepend_mod_with('Boards::BaseService') diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb index 54dab581686..5f014abe071 100644 --- a/app/services/boards/create_service.rb +++ b/app/services/boards/create_service.rb @@ -37,4 +37,4 @@ module Boards end end -Boards::CreateService.prepend_if_ee('EE::Boards::CreateService') +Boards::CreateService.prepend_mod_with('Boards::CreateService') diff --git a/app/services/boards/issues/create_service.rb b/app/services/boards/issues/create_service.rb index 1769966a049..0639acfb399 100644 --- a/app/services/boards/issues/create_service.rb +++ b/app/services/boards/issues/create_service.rb @@ -30,10 +30,10 @@ module Boards end def create_issue(params) - ::Issues::CreateService.new(project, current_user, params).execute + ::Issues::CreateService.new(project: project, current_user: current_user, params: params).execute end end end end -Boards::Issues::CreateService.prepend_if_ee('EE::Boards::Issues::CreateService') +Boards::Issues::CreateService.prepend_mod_with('Boards::Issues::CreateService') diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index c6855f29af0..6284e454561 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -50,4 +50,4 @@ module Boards end end -Boards::Issues::ListService.prepend_if_ee('EE::Boards::Issues::ListService') +Boards::Issues::ListService.prepend_mod_with('Boards::Issues::ListService') diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 76ea57968b2..959a7fa3ad2 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -52,7 +52,7 @@ module Boards end def update(issue, issue_modification_params) - ::Issues::UpdateService.new(issue.project, current_user, issue_modification_params).execute(issue) + ::Issues::UpdateService.new(project: issue.project, current_user: current_user, params: issue_modification_params).execute(issue) end def reposition_parent @@ -62,4 +62,4 @@ module Boards end end -Boards::Issues::MoveService.prepend_if_ee('EE::Boards::Issues::MoveService') +Boards::Issues::MoveService.prepend_mod_with('Boards::Issues::MoveService') diff --git a/app/services/boards/lists/base_destroy_service.rb b/app/services/boards/lists/base_destroy_service.rb new file mode 100644 index 00000000000..dc0247c40b0 --- /dev/null +++ b/app/services/boards/lists/base_destroy_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Boards + module Lists + # This class is used by issue and epic board lists + # for destroying a single list + class BaseDestroyService < Boards::BaseService + def execute(list) + unless list.destroyable? + return ServiceResponse.error(message: "Open and closed lists on a board cannot be destroyed.") + end + + list.with_lock do + decrement_higher_lists(list) + list.destroy! + end + + ServiceResponse.success + rescue StandardError => e + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e) + ServiceResponse.error(message: "List destroy failed.") + end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def decrement_higher_lists(list) + list.board.lists.movable.where('position > ?', list.position) + .update_all('position = position - 1') + end + # rubocop: enable CodeReuse/ActiveRecord + end + end +end diff --git a/app/services/boards/lists/base_update_service.rb b/app/services/boards/lists/base_update_service.rb index faf58e405fc..bcb7d6c8504 100644 --- a/app/services/boards/lists/base_update_service.rb +++ b/app/services/boards/lists/base_update_service.rb @@ -3,16 +3,30 @@ module Boards module Lists class BaseUpdateService < Boards::BaseService + extend ::Gitlab::Utils::Override + def execute(list) if execute_by_params(list) success(list: list) else - error(list.errors.messages, 422) + message = list.errors.empty? ? 'The update was not successful.' : list.errors.messages + + error(message, { list: list }) end end private + override :error + def error(message, pass_back = {}) + ServiceResponse.error(message: message, http_status: :unprocessable_entity, payload: pass_back) + end + + override :success + def success(pass_back = {}) + ServiceResponse.success(payload: pass_back) + end + def execute_by_params(list) update_preferences_result = update_preferences(list) if can_read?(list) update_position_result = update_position(list) if can_admin?(list) diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb index 37fe0a815bd..3ee0b6d8821 100644 --- a/app/services/boards/lists/create_service.rb +++ b/app/services/boards/lists/create_service.rb @@ -7,4 +7,4 @@ module Boards end end -Boards::Lists::CreateService.prepend_if_ee('EE::Boards::Lists::CreateService') +Boards::Lists::CreateService.prepend_mod_with('Boards::Lists::CreateService') diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb index ebac0f07fe1..10d9f275d3f 100644 --- a/app/services/boards/lists/destroy_service.rb +++ b/app/services/boards/lists/destroy_service.rb @@ -2,36 +2,8 @@ module Boards module Lists - class DestroyService < Boards::BaseService - def execute(list) - unless list.destroyable? - return ServiceResponse.error(message: "The list cannot be destroyed. Only label lists can be destroyed.") - end - - @board = list.board - - list.with_lock do - decrement_higher_lists(list) - remove_list(list) - end - - ServiceResponse.success - end - - private - - attr_reader :board - - # rubocop: disable CodeReuse/ActiveRecord - def decrement_higher_lists(list) - board.lists.movable.where('position > ?', list.position) - .update_all('position = position - 1') - end - # rubocop: enable CodeReuse/ActiveRecord - - def remove_list(list) - list.destroy! - end + # overridden in EE for board lists and also for epic board lists. + class DestroyService < Boards::Lists::BaseDestroyService end end end diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb index 03d54a8c74c..e81ef467a4e 100644 --- a/app/services/boards/lists/list_service.rb +++ b/app/services/boards/lists/list_service.rb @@ -32,4 +32,4 @@ module Boards end end -Boards::Lists::ListService.prepend_if_ee('EE::Boards::Lists::ListService') +Boards::Lists::ListService.prepend_mod_with('Boards::Lists::ListService') diff --git a/app/services/boards/lists/update_service.rb b/app/services/boards/lists/update_service.rb index 2e1a6592cd9..5c24c0daa73 100644 --- a/app/services/boards/lists/update_service.rb +++ b/app/services/boards/lists/update_service.rb @@ -14,4 +14,4 @@ module Boards end end -Boards::Lists::UpdateService.prepend_if_ee('EE::Boards::Lists::UpdateService') +Boards::Lists::UpdateService.prepend_mod_with('Boards::Lists::UpdateService') diff --git a/app/services/boards/update_service.rb b/app/services/boards/update_service.rb index 48c6e44d55e..6ba8f68a4cb 100644 --- a/app/services/boards/update_service.rb +++ b/app/services/boards/update_service.rb @@ -19,4 +19,4 @@ module Boards end end -Boards::UpdateService.prepend_if_ee('EE::Boards::UpdateService') +Boards::UpdateService.prepend_mod_with('Boards::UpdateService') diff --git a/app/services/boards/visits/create_service.rb b/app/services/boards/visits/create_service.rb index 428ed1a8bcc..4d659596803 100644 --- a/app/services/boards/visits/create_service.rb +++ b/app/services/boards/visits/create_service.rb @@ -5,13 +5,17 @@ module Boards class CreateService < Boards::BaseService def execute(board) return unless current_user && Gitlab::Database.read_write? - return unless board.is_a?(Board) # other board types do not support board visits yet + return unless board - if parent.is_a?(Group) - BoardGroupRecentVisit.visited!(current_user, board) - else - BoardProjectRecentVisit.visited!(current_user, board) - end + model.visited!(current_user, board) + end + + private + + def model + return BoardGroupRecentVisit if parent.is_a?(Group) + + BoardProjectRecentVisit end end end diff --git a/app/services/bulk_create_integration_service.rb b/app/services/bulk_create_integration_service.rb index ae756d0856e..adb989be218 100644 --- a/app/services/bulk_create_integration_service.rb +++ b/app/services/bulk_create_integration_service.rb @@ -10,7 +10,7 @@ class BulkCreateIntegrationService def execute service_list = ServiceList.new(batch, service_hash, association).to_array - Service.transaction do + Integration.transaction do results = bulk_insert(*service_list) if integration.data_fields_present? diff --git a/app/services/bulk_imports/export_service.rb b/app/services/bulk_imports/export_service.rb new file mode 100644 index 00000000000..33b3a8e187f --- /dev/null +++ b/app/services/bulk_imports/export_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module BulkImports + class ExportService + def initialize(portable:, user:) + @portable = portable + @current_user = user + end + + def execute + FileTransfer.config_for(portable).portable_relations.each do |relation| + RelationExportWorker.perform_async(current_user.id, portable.id, portable.class.name, relation) + end + + ServiceResponse.success + rescue StandardError => e + ServiceResponse.error( + message: e.class, + http_status: :unprocessable_entity + ) + end + + private + + attr_reader :portable, :current_user + end +end diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb new file mode 100644 index 00000000000..53952a33b5f --- /dev/null +++ b/app/services/bulk_imports/relation_export_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module BulkImports + class RelationExportService + include Gitlab::ImportExport::CommandLineUtil + + def initialize(user, portable, relation, jid) + @user = user + @portable = portable + @relation = relation + @jid = jid + end + + def execute + find_or_create_export! do |export| + remove_existing_export_file!(export) + serialize_relation_to_file(export.relation_definition) + compress_exported_relation + upload_compressed_file(export) + end + end + + private + + attr_reader :user, :portable, :relation, :jid + + def find_or_create_export! + validate_user_permissions! + + export = portable.bulk_import_exports.safe_find_or_create_by!(relation: relation) + export.update!(status_event: 'start', jid: jid) + + yield export + + export.update!(status_event: 'finish', error: nil) + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, portable_id: portable.id, portable_type: portable.class.name) + + export&.update(status_event: 'fail_op', error: e.class) + end + + def validate_user_permissions! + ability = "admin_#{portable.to_ability_name}" + + user.can?(ability, portable) || + raise(::Gitlab::ImportExport::Error.permission_error(user, portable)) + end + + def remove_existing_export_file!(export) + upload = export.upload + + return unless upload&.export_file&.file + + upload.remove_export_file! + upload.save! + end + + def serialize_relation_to_file(relation_definition) + serializer.serialize_relation(relation_definition) + end + + def compress_exported_relation + gzip(dir: export_path, filename: ndjson_filename) + end + + def upload_compressed_file(export) + compressed_filename = File.join(export_path, "#{ndjson_filename}.gz") + upload = ExportUpload.find_or_initialize_by(export_id: export.id) # rubocop: disable CodeReuse/ActiveRecord + + File.open(compressed_filename) { |file| upload.export_file = file } + + upload.save! + end + + def config + @config ||= FileTransfer.config_for(portable) + end + + def export_path + @export_path ||= config.export_path + end + + def portable_tree + @portable_tree ||= config.portable_tree + end + + # rubocop: disable CodeReuse/Serializer + def serializer + @serializer ||= ::Gitlab::ImportExport::JSON::StreamingSerializer.new( + portable, + portable_tree, + json_writer, + exportable_path: '' + ) + end + # rubocop: enable CodeReuse/Serializer + + def json_writer + @json_writer ||= ::Gitlab::ImportExport::JSON::NdjsonWriter.new(export_path) + end + + def ndjson_filename + @ndjson_filename ||= "#{relation}.ndjson" + end + end +end diff --git a/app/services/bulk_update_integration_service.rb b/app/services/bulk_update_integration_service.rb index 5ddfdd359c2..29cfd824c12 100644 --- a/app/services/bulk_update_integration_service.rb +++ b/app/services/bulk_update_integration_service.rb @@ -8,8 +8,8 @@ class BulkUpdateIntegrationService # rubocop: disable CodeReuse/ActiveRecord def execute - Service.transaction do - Service.where(id: batch.select(:id)).update_all(service_hash) + Integration.transaction do + Integration.where(id: batch.select(:id)).update_all(service_hash) if integration.data_fields_present? integration.data_fields.class.where(service_id: batch.select(:id)).update_all(data_fields_hash) diff --git a/app/services/chat_names/find_user_service.rb b/app/services/chat_names/find_user_service.rb index c91738fa4c7..3dd3ba7f01c 100644 --- a/app/services/chat_names/find_user_service.rb +++ b/app/services/chat_names/find_user_service.rb @@ -2,8 +2,8 @@ module ChatNames class FindUserService - def initialize(service, params) - @service = service + def initialize(integration, params) + @integration = integration @params = params end @@ -20,7 +20,7 @@ module ChatNames # rubocop: disable CodeReuse/ActiveRecord def find_chat_name ChatName.find_by( - service: @service, + integration: @integration, team_id: @params[:team_id], chat_id: @params[:user_id] ) diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb index 3858ee9d550..2b611c857c7 100644 --- a/app/services/ci/after_requeue_job_service.rb +++ b/app/services/ci/after_requeue_job_service.rb @@ -4,7 +4,7 @@ module Ci class AfterRequeueJobService < ::BaseService def execute(processable) process_subsequent_jobs(processable) - reset_ancestor_bridges(processable) + reset_source_bridge(processable) end private @@ -15,8 +15,8 @@ module Ci end end - def reset_ancestor_bridges(processable) - processable.pipeline.reset_ancestor_bridges! + def reset_source_bridge(processable) + processable.pipeline.reset_source_bridge!(current_user) end def process(processable) diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb index 9b2c7788897..bc3219fbd79 100644 --- a/app/services/ci/archive_trace_service.rb +++ b/app/services/ci/archive_trace_service.rb @@ -24,7 +24,7 @@ module Ci end rescue ::Gitlab::Ci::Trace::AlreadyArchivedError # It's already archived, thus we can safely ignore this exception. - rescue => e + rescue StandardError => e # Tracks this error with application logs, Sentry, and Prometheus. # If `archive!` keeps failing for over a week, that could incur data loss. # (See more https://docs.gitlab.com/ee/administration/job_logs.html#new-incremental-logging-architecture) diff --git a/app/services/ci/change_variable_service.rb b/app/services/ci/change_variable_service.rb index f515a335d54..83cd6aae14b 100644 --- a/app/services/ci/change_variable_service.rb +++ b/app/services/ci/change_variable_service.rb @@ -30,4 +30,4 @@ module Ci end end -::Ci::ChangeVariableService.prepend_if_ee('EE::Ci::ChangeVariableService') +::Ci::ChangeVariableService.prepend_mod_with('Ci::ChangeVariableService') diff --git a/app/services/ci/change_variables_service.rb b/app/services/ci/change_variables_service.rb index 3337eb09411..7a68bd2e2b3 100644 --- a/app/services/ci/change_variables_service.rb +++ b/app/services/ci/change_variables_service.rb @@ -8,4 +8,4 @@ module Ci end end -::Ci::ChangeVariablesService.prepend_if_ee('EE::Ci::ChangeVariablesService') +::Ci::ChangeVariablesService.prepend_mod_with('Ci::ChangeVariablesService') diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb index 93f0338fcba..64a99e404c6 100644 --- a/app/services/ci/create_downstream_pipeline_service.rb +++ b/app/services/ci/create_downstream_pipeline_service.rb @@ -85,6 +85,12 @@ module Ci return false end + if has_cyclic_dependency? + @bridge.drop!(:pipeline_loop_detected) + + return false + end + true end @@ -109,11 +115,24 @@ module Ci end end + def has_cyclic_dependency? + return false if @bridge.triggers_child_pipeline? + + if Feature.enabled?(:ci_drop_cyclical_triggered_pipelines, @bridge.project, default_enabled: :yaml) + checksums = @bridge.pipeline.base_and_ancestors.map { |pipeline| config_checksum(pipeline) } + checksums.uniq.length != checksums.length + end + end + def has_max_descendants_depth? return false unless @bridge.triggers_child_pipeline? ancestors_of_new_child = @bridge.pipeline.base_and_ancestors(same_project: true) ancestors_of_new_child.count > MAX_DESCENDANTS_DEPTH end + + def config_checksum(pipeline) + [pipeline.project_id, pipeline.ref].hash + end end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index ca936307acc..fd333e24860 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -10,6 +10,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::Build::Associations, Gitlab::Ci::Pipeline::Chain::Validate::Abilities, Gitlab::Ci::Pipeline::Chain::Validate::Repository, + Gitlab::Ci::Pipeline::Chain::Validate::SecurityOrchestrationPolicy, Gitlab::Ci::Pipeline::Chain::Config::Content, Gitlab::Ci::Pipeline::Chain::Config::Process, Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, @@ -84,7 +85,6 @@ module Ci if pipeline.persisted? schedule_head_pipeline_update - record_conversion_event create_namespace_onboarding_action end @@ -122,12 +122,6 @@ module Ci end end - def record_conversion_event - return unless project.namespace.recent? - - Experiments::RecordConversionEventWorker.perform_async(:ci_syntax_templates_b, current_user.id) - end - def create_namespace_onboarding_action Namespaces::OnboardingPipelineCreatedWorker.perform_async(project.namespace_id) end @@ -138,4 +132,4 @@ module Ci end end -Ci::CreatePipelineService.prepend_if_ee('EE::Ci::CreatePipelineService') +Ci::CreatePipelineService.prepend_mod_with('Ci::CreatePipelineService') diff --git a/app/services/ci/create_web_ide_terminal_service.rb b/app/services/ci/create_web_ide_terminal_service.rb index 3b89a599180..db8f61c81fa 100644 --- a/app/services/ci/create_web_ide_terminal_service.rb +++ b/app/services/ci/create_web_ide_terminal_service.rb @@ -28,6 +28,11 @@ module Ci def create_pipeline! build_pipeline.tap do |pipeline| pipeline.stages << terminal_stage_seed(pipeline).to_resource + + # Project iid must be called outside a transaction, so we ensure it is set here + # otherwise it may be set within the save! which it will lock the InternalId row for the whole transaction + pipeline.ensure_project_iid! + pipeline.save! Ci::ProcessPipelineService diff --git a/app/services/ci/delete_unit_tests_service.rb b/app/services/ci/delete_unit_tests_service.rb new file mode 100644 index 00000000000..28f96351175 --- /dev/null +++ b/app/services/ci/delete_unit_tests_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Ci + class DeleteUnitTestsService + include EachBatch + + BATCH_SIZE = 100 + + def execute + purge_data!(Ci::UnitTestFailure) + purge_data!(Ci::UnitTest) + end + + private + + def purge_data!(klass) + loop do + break unless delete_batch!(klass) + end + end + + # rubocop: disable CodeReuse/ActiveRecord + def delete_batch!(klass) + deleted = 0 + + ActiveRecord::Base.transaction do + ids = klass.deletable.lock('FOR UPDATE SKIP LOCKED').limit(BATCH_SIZE).pluck(:id) + break if ids.empty? + + deleted = klass.where(id: ids).delete_all + end + + deleted > 0 + end + # rubocop: enable CodeReuse/ActiveRecord + end +end diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb index 2ae60907dab..80c83818d0b 100644 --- a/app/services/ci/expire_pipeline_cache_service.rb +++ b/app/services/ci/expire_pipeline_cache_service.rb @@ -56,6 +56,10 @@ module Ci url_helpers.graphql_etag_pipeline_path(pipeline) end + def graphql_pipeline_sha_path(sha) + url_helpers.graphql_etag_pipeline_sha_path(sha) + end + # Updates ETag caches of a pipeline. # # This logic resides in a separate method so that EE can more easily extend @@ -76,6 +80,7 @@ module Ci pipeline.self_with_ancestors_and_descendants.each do |relative_pipeline| store.touch(project_pipeline_path(relative_pipeline.project, relative_pipeline)) store.touch(graphql_pipeline_path(relative_pipeline)) + store.touch(graphql_pipeline_sha_path(relative_pipeline.sha)) end end diff --git a/app/services/ci/generate_codequality_mr_diff_report_service.rb b/app/services/ci/generate_codequality_mr_diff_report_service.rb index 3b1bd319a4f..117b0a21eaa 100644 --- a/app/services/ci/generate_codequality_mr_diff_report_service.rb +++ b/app/services/ci/generate_codequality_mr_diff_report_service.rb @@ -12,9 +12,9 @@ module Ci { status: :parsed, key: key(base_pipeline, head_pipeline), - data: head_pipeline.pipeline_artifacts.find_by_file_type(:code_quality_mr_diff).present.for_files(merge_request.new_paths) + data: head_pipeline.pipeline_artifacts.find_by_file_type(:code_quality_mr_diff).present.for_files(merge_request) } - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, project_id: project.id) { status: :error, diff --git a/app/services/ci/generate_coverage_reports_service.rb b/app/services/ci/generate_coverage_reports_service.rb index 4e6fbc5462a..12b1f19f4b5 100644 --- a/app/services/ci/generate_coverage_reports_service.rb +++ b/app/services/ci/generate_coverage_reports_service.rb @@ -14,7 +14,7 @@ module Ci key: key(base_pipeline, head_pipeline), data: head_pipeline.pipeline_artifacts.find_by_file_type(:code_coverage).present.for_files(merge_request.new_paths) } - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception( e, project_id: project.id, diff --git a/app/services/ci/generate_exposed_artifacts_report_service.rb b/app/services/ci/generate_exposed_artifacts_report_service.rb index 1dbcd192279..dfa7cbd7d98 100644 --- a/app/services/ci/generate_exposed_artifacts_report_service.rb +++ b/app/services/ci/generate_exposed_artifacts_report_service.rb @@ -14,7 +14,7 @@ module Ci key: key(base_pipeline, head_pipeline), data: data } - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, project_id: project.id) { status: :error, diff --git a/app/services/ci/generate_terraform_reports_service.rb b/app/services/ci/generate_terraform_reports_service.rb index d768ce777d4..0ffb2d7e34a 100644 --- a/app/services/ci/generate_terraform_reports_service.rb +++ b/app/services/ci/generate_terraform_reports_service.rb @@ -13,7 +13,7 @@ module Ci key: key(base_pipeline, head_pipeline), data: head_pipeline.terraform_reports.plans } - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, project_id: project.id) { status: :error, diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb index 65752e56c64..a22ac87f660 100644 --- a/app/services/ci/job_artifacts/create_service.rb +++ b/app/services/ci/job_artifacts/create_service.rb @@ -136,7 +136,7 @@ module Ci rescue *OBJECT_STORAGE_ERRORS => error track_exception(error, params) error(error.message, :service_unavailable) - rescue => error + rescue StandardError => error track_exception(error, params) error(error.message, :bad_request) end diff --git a/app/services/ci/job_artifacts/destroy_associations_service.rb b/app/services/ci/job_artifacts/destroy_associations_service.rb new file mode 100644 index 00000000000..794d24eadf2 --- /dev/null +++ b/app/services/ci/job_artifacts/destroy_associations_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Ci + module JobArtifacts + class DestroyAssociationsService + BATCH_SIZE = 100 + + def initialize(job_artifacts_relation) + @job_artifacts_relation = job_artifacts_relation + @statistics = {} + end + + def destroy_records + @job_artifacts_relation.each_batch(of: BATCH_SIZE) do |relation| + service = Ci::JobArtifacts::DestroyBatchService.new(relation, pick_up_at: Time.current) + result = service.execute(update_stats: false) + updates = result[:statistics_updates] + + @statistics.merge!(updates) { |_key, oldval, newval| newval + oldval } + end + end + + def update_statistics + @statistics.each do |project, delta| + project.increment_statistic_value(Ci::JobArtifact.project_statistics_name, delta) + end + end + end + end +end diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb index 95315dd11ec..8536b88ccc0 100644 --- a/app/services/ci/job_artifacts/destroy_batch_service.rb +++ b/app/services/ci/job_artifacts/destroy_batch_service.rb @@ -23,8 +23,8 @@ module Ci end # rubocop: disable CodeReuse/ActiveRecord - def execute - return success(destroyed_artifacts_count: artifacts_count) if @job_artifacts.empty? + def execute(update_stats: true) + return success(destroyed_artifacts_count: 0, statistics_updates: {}) if @job_artifacts.empty? Ci::DeletedObject.transaction do Ci::DeletedObject.bulk_import(@job_artifacts, @pick_up_at) @@ -33,10 +33,11 @@ module Ci end # This is executed outside of the transaction because it depends on Redis - update_project_statistics + update_project_statistics! if update_stats increment_monitoring_statistics(artifacts_count) - success(destroyed_artifacts_count: artifacts_count) + success(destroyed_artifacts_count: artifacts_count, + statistics_updates: affected_project_statistics) end # rubocop: enable CodeReuse/ActiveRecord @@ -45,12 +46,20 @@ module Ci # This method is implemented in EE and it must do only database work def destroy_related_records(artifacts); end - def update_project_statistics - artifacts_by_project = @job_artifacts.group_by(&:project) - artifacts_by_project.each do |project, artifacts| - delta = -artifacts.sum { |artifact| artifact.size.to_i } - ProjectStatistics.increment_statistic( - project, Ci::JobArtifact.project_statistics_name, delta) + # using ! here since this can't be called inside a transaction + def update_project_statistics! + affected_project_statistics.each do |project, delta| + project.increment_statistic_value(Ci::JobArtifact.project_statistics_name, delta) + end + end + + def affected_project_statistics + strong_memoize(:affected_project_statistics) do + artifacts_by_project = @job_artifacts.group_by(&:project) + artifacts_by_project.each.with_object({}) do |(project, artifacts), accumulator| + delta = -artifacts.sum { |artifact| artifact.size.to_i } + accumulator[project] = delta + end end end @@ -71,4 +80,4 @@ module Ci end end -Ci::JobArtifacts::DestroyBatchService.prepend_if_ee('EE::Ci::JobArtifacts::DestroyBatchService') +Ci::JobArtifacts::DestroyBatchService.prepend_mod_with('Ci::JobArtifacts::DestroyBatchService') diff --git a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb index 5c52eef7ba6..d6865efac9f 100644 --- a/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb +++ b/app/services/ci/pipeline_artifacts/create_code_quality_mr_diff_report_service.rb @@ -2,11 +2,18 @@ module Ci module PipelineArtifacts class CreateCodeQualityMrDiffReportService - def execute(pipeline) + include Gitlab::Utils::StrongMemoize + + def initialize(pipeline) + @pipeline = pipeline + end + + def execute return unless pipeline.can_generate_codequality_reports? return if pipeline.has_codequality_mr_diff_report? + return unless new_errors_introduced? - file = build_carrierwave_file(pipeline) + file = build_carrierwave_file! pipeline.pipeline_artifacts.create!( project_id: pipeline.project_id, @@ -20,18 +27,54 @@ module Ci private - def build_carrierwave_file(pipeline) + attr_reader :pipeline + + def merge_requests + strong_memoize(:merge_requests) do + pipeline.merge_requests_as_head_pipeline + end + end + + def head_report + strong_memoize(:head_report) do + pipeline.codequality_reports + end + end + + def base_report(merge_request) + strong_memoize(:base_report) do + merge_request&.base_pipeline&.codequality_reports + end + end + + def mr_diff_report_by_merge_requests + strong_memoize(:mr_diff_report_by_merge_requests) do + merge_requests.each_with_object({}) do |merge_request, hash| + key = "merge_request_#{merge_request.id}" + new_errors = Gitlab::Ci::Reports::CodequalityReportsComparer.new(base_report(merge_request), head_report).new_errors + next if new_errors.empty? + + hash[key] = Gitlab::Ci::Reports::CodequalityMrDiff.new(new_errors) + end + end + end + + def new_errors_introduced? + mr_diff_report_by_merge_requests.present? + end + + def build_carrierwave_file! CarrierWaveStringFile.new_file( - file_content: build_quality_mr_diff_report(pipeline), + file_content: build_quality_mr_diff_report(mr_diff_report_by_merge_requests), filename: Ci::PipelineArtifact::DEFAULT_FILE_NAMES.fetch(:code_quality_mr_diff), content_type: 'application/json' ) end - def build_quality_mr_diff_report(pipeline) - mr_diff_report = Gitlab::Ci::Reports::CodequalityMrDiff.new(pipeline.codequality_reports) - - Ci::CodequalityMrDiffReportSerializer.new.represent(mr_diff_report).to_json # rubocop: disable CodeReuse/Serializer + def build_quality_mr_diff_report(mr_diff_report) + mr_diff_report.each_with_object({}) do |diff_report, hash| + hash[diff_report.first] = Ci::CodequalityMrDiffReportSerializer.new.represent(diff_report.second) # rubocop: disable CodeReuse/Serializer + end.to_json end end end diff --git a/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb b/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb index fed40aef697..7b6590a117c 100644 --- a/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb +++ b/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb @@ -25,7 +25,7 @@ module Ci private def destroy_artifacts_batch - artifacts = ::Ci::PipelineArtifact.expired(BATCH_SIZE).to_a + artifacts = ::Ci::PipelineArtifact.unlocked.expired(BATCH_SIZE).to_a return false if artifacts.empty? artifacts.each(&:destroy!) diff --git a/app/services/ci/pipeline_bridge_status_service.rb b/app/services/ci/pipeline_bridge_status_service.rb index e2e5dd386f2..aeac43588f7 100644 --- a/app/services/ci/pipeline_bridge_status_service.rb +++ b/app/services/ci/pipeline_bridge_status_service.rb @@ -17,4 +17,4 @@ module Ci end end -Ci::PipelineBridgeStatusService.prepend_if_ee('EE::Ci::PipelineBridgeStatusService') +Ci::PipelineBridgeStatusService.prepend_mod_with('Ci::PipelineBridgeStatusService') diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb index a5f70d62e13..62c4d6b4599 100644 --- a/app/services/ci/pipeline_trigger_service.rb +++ b/app/services/ci/pipeline_trigger_service.rb @@ -3,6 +3,7 @@ module Ci class PipelineTriggerService < BaseService include Gitlab::Utils::StrongMemoize + include Services::ReturnServiceResponses def execute if trigger_from_token @@ -20,7 +21,7 @@ module Ci private PAYLOAD_VARIABLE_KEY = 'TRIGGER_PAYLOAD' - PAYLOAD_VARIABLE_HIDDEN_PARAMS = %i(token).freeze + PAYLOAD_VARIABLE_HIDDEN_PARAMS = %i[token].freeze def create_pipeline_from_trigger(trigger) # this check is to not leak the presence of the project if user cannot read it @@ -32,10 +33,17 @@ module Ci pipeline.trigger_requests.build(trigger: trigger) end - if pipeline.persisted? + pipeline_service_response(pipeline) + end + + def pipeline_service_response(pipeline) + if pipeline.created_successfully? success(pipeline: pipeline) + elsif pipeline.persisted? + err = pipeline.errors.messages.presence || pipeline.failure_reason.presence || 'Could not create pipeline' + error(err, :unprocessable_entity) else - error(pipeline.errors.messages, 400) + error(pipeline.errors.messages, :bad_request) end end @@ -61,11 +69,7 @@ module Ci pipeline.source_pipeline = source end - if pipeline.persisted? - success(pipeline: pipeline) - else - error(pipeline.errors.messages, 400) - end + pipeline_service_response(pipeline) end def job_from_token diff --git a/app/services/ci/prepare_build_service.rb b/app/services/ci/prepare_build_service.rb index 3f87c711270..ec61c43cce9 100644 --- a/app/services/ci/prepare_build_service.rb +++ b/app/services/ci/prepare_build_service.rb @@ -12,7 +12,7 @@ module Ci prerequisites.each(&:complete!) build.enqueue_preparing! - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, build_id: build.id) build.drop(:unmet_prerequisites) diff --git a/app/services/ci/process_build_service.rb b/app/services/ci/process_build_service.rb index 73cf3308fe7..5271c0fe93d 100644 --- a/app/services/ci/process_build_service.rb +++ b/app/services/ci/process_build_service.rb @@ -40,4 +40,4 @@ module Ci end end -Ci::ProcessBuildService.prepend_if_ee('EE::Ci::ProcessBuildService') +Ci::ProcessBuildService.prepend_mod_with('Ci::ProcessBuildService') diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 6c69df0c616..fb26d5d3356 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -48,7 +48,13 @@ module Ci # This counter is temporary. It will be used to check whether if we still use this method or not # after setting correct value of `GenericCommitStatus#retried`. # More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50465#note_491657115 - metrics.legacy_update_jobs_counter.increment if updated_count > 0 + if updated_count > 0 + Gitlab::AppJsonLogger.info(event: 'update_retried_is_used', + project_id: pipeline.project.id, + pipeline_id: pipeline.id) + + metrics.legacy_update_jobs_counter.increment + end end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/ci/prometheus_metrics/observe_histograms_service.rb b/app/services/ci/prometheus_metrics/observe_histograms_service.rb index 527d87f19c2..6bd3d2121ba 100644 --- a/app/services/ci/prometheus_metrics/observe_histograms_service.rb +++ b/app/services/ci/prometheus_metrics/observe_histograms_service.rb @@ -25,8 +25,6 @@ module Ci end def execute - return ServiceResponse.success(http_status: :accepted) unless enabled? - params .fetch(:histograms, []) .each(&method(:observe)) @@ -48,10 +46,6 @@ module Ci .fetch(name) { raise ActiveRecord::RecordNotFound } .call end - - def enabled? - ::Feature.enabled?(:ci_accept_frontend_prometheus_metrics, project, default_enabled: :yaml) - end end end end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 90341b26fd6..461647ffccc 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -169,7 +169,7 @@ module Ci @metrics.increment_queue_operation(:build_conflict_transition) Result.new(nil, nil, false) - rescue => ex + rescue StandardError => ex @metrics.increment_queue_operation(:build_conflict_exception) # If an error (e.g. GRPC::DeadlineExceeded) occurred constructing @@ -233,7 +233,7 @@ module Ci Gitlab::OptimisticLocking.retry_lock(build, 3, name: 'register_job_scheduler_failure') do |subject| subject.drop!(:scheduler_failure) end - rescue => ex + rescue StandardError => ex build.doom! # This requires extra exception, otherwise we would loose information @@ -253,17 +253,23 @@ module Ci # rubocop: disable CodeReuse/ActiveRecord def builds_for_shared_runner - new_builds. + relation = new_builds. # don't run projects which have not enabled shared runners and builds joins(:project).where(projects: { shared_runners_enabled: true, pending_delete: false }) .joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id') - .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0'). + .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0') - # Implement fair scheduling - # this returns builds that are ordered by number of running builds - # we prefer projects that don't use shared runners at all - joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id") - .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC') + if Feature.enabled?(:ci_queueing_disaster_recovery, runner, type: :ops, default_enabled: :yaml) + # if disaster recovery is enabled, we fallback to FIFO scheduling + relation.order('ci_builds.id ASC') + else + # Implement fair scheduling + # this returns builds that are ordered by number of running builds + # we prefer projects that don't use shared runners at all + relation + .joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id") + .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC') + end end # rubocop: enable CodeReuse/ActiveRecord @@ -310,4 +316,4 @@ module Ci end end -Ci::RegisterJobService.prepend_if_ee('EE::Ci::RegisterJobService') +Ci::RegisterJobService.prepend_mod_with('Ci::RegisterJobService') diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index e3de7f43fda..e03f2ae3d52 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -18,16 +18,14 @@ module Ci AfterRequeueJobService.new(project, current_user).execute(build) ::MergeRequests::AddTodoWhenBuildFailsService - .new(project, current_user) + .new(project: project, current_user: current_user) .close(new_build) end end # rubocop: disable CodeReuse/ActiveRecord def reprocess!(build) - unless can?(current_user, :update_build, build) - raise Gitlab::Access::AccessDeniedError - end + check_access!(build) attributes = self.class.clone_accessors.to_h do |attribute| [attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend @@ -52,6 +50,12 @@ module Ci private + def check_access!(build) + unless can?(current_user, :update_build, build) + raise Gitlab::Access::AccessDeniedError + end + end + def create_build!(attributes) build = project.builds.new(attributes) build.assign_attributes(::Gitlab::Ci::Pipeline::Seed::Build.environment_attributes_for(build)) @@ -64,4 +68,4 @@ module Ci end end -Ci::RetryBuildService.prepend_if_ee('EE::Ci::RetryBuildService') +Ci::RetryBuildService.prepend_mod_with('Ci::RetryBuildService') diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index bb8590a769c..5cc6b89bfef 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -26,10 +26,10 @@ module Ci retry_optimistic_lock(skipped, name: 'ci_retry_pipeline') { |build| build.process(current_user) } end - pipeline.reset_ancestor_bridges! + pipeline.reset_source_bridge!(current_user) ::MergeRequests::AddTodoWhenBuildFailsService - .new(project, current_user) + .new(project: project, current_user: current_user) .close_all(pipeline) Ci::ProcessPipelineService diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb index 81457130fa0..7c9fc44e7f4 100644 --- a/app/services/ci/stop_environments_service.rb +++ b/app/services/ci/stop_environments_service.rb @@ -27,7 +27,7 @@ module Ci stop_actions.each do |stop_action| stop_action.play(stop_action.user) - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, deployable_id: stop_action.id) end end @@ -35,7 +35,7 @@ module Ci private def environments - @environments ||= EnvironmentsByDeploymentsFinder + @environments ||= Environments::EnvironmentsByDeploymentsFinder .new(project, current_user, ref: @ref, recently_updated: true) .execute end diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb index 58bbc716ff0..a3f45c1b9cd 100644 --- a/app/services/ci/test_failure_history_service.rb +++ b/app/services/ci/test_failure_history_service.rb @@ -30,7 +30,7 @@ module Ci end def should_track_failures? - return false unless project.default_branch_or_master == pipeline.ref + return false unless project.default_branch_or_main == pipeline.ref # We fetch for up to MAX_TRACKABLE_FAILURES + 1 builds. So if ever we get # 201 total number of builds with the assumption that each job has at least diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index 249abd3ff9d..10a12f30956 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -19,9 +19,7 @@ module Clusters def check_timeout if timed_out? - begin - app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.") - end + app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.") else ClusterWaitForAppInstallationWorker.perform_in( ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) diff --git a/app/services/clusters/applications/check_upgrade_progress_service.rb b/app/services/clusters/applications/check_upgrade_progress_service.rb index bc161218618..c4fd234b302 100644 --- a/app/services/clusters/applications/check_upgrade_progress_service.rb +++ b/app/services/clusters/applications/check_upgrade_progress_service.rb @@ -51,7 +51,7 @@ module Clusters def remove_pod helm_api.delete_pod!(pod_name) - rescue + rescue StandardError # no-op end diff --git a/app/services/clusters/applications/prometheus_config_service.rb b/app/services/clusters/applications/prometheus_config_service.rb index 50c4e26b0d0..d39d63c874f 100644 --- a/app/services/clusters/applications/prometheus_config_service.rb +++ b/app/services/clusters/applications/prometheus_config_service.rb @@ -96,8 +96,6 @@ module Clusters end def alert_manager_token - app.generate_alert_manager_token! - app.alert_manager_token end diff --git a/app/services/clusters/applications/prometheus_update_service.rb b/app/services/clusters/applications/prometheus_update_service.rb index 437f6ab1202..b8b50f06d72 100644 --- a/app/services/clusters/applications/prometheus_update_service.rb +++ b/app/services/clusters/applications/prometheus_update_service.rb @@ -2,6 +2,7 @@ module Clusters module Applications + # Deprecated, to be removed in %14.0 as part of https://gitlab.com/groups/gitlab-org/-/epics/4280 class PrometheusUpdateService < BaseHelmService attr_accessor :project @@ -11,6 +12,8 @@ module Clusters end def execute + raise NotImplementedError, 'Externally installed prometheus should not be modified!' unless app.managed_prometheus? + app.make_updating! helm_api.update(patch_command(values)) diff --git a/app/services/clusters/applications/schedule_update_service.rb b/app/services/clusters/applications/schedule_update_service.rb index 41718df9a98..4f130f76b87 100644 --- a/app/services/clusters/applications/schedule_update_service.rb +++ b/app/services/clusters/applications/schedule_update_service.rb @@ -14,6 +14,7 @@ module Clusters def execute return unless application + return unless application.managed_prometheus? if recently_scheduled? worker_class.perform_in(BACKOFF_DELAY, application.name, application.id, project.id, Time.current) diff --git a/app/services/clusters/aws/fetch_credentials_service.rb b/app/services/clusters/aws/fetch_credentials_service.rb index 497e676f549..e38852c7ec7 100644 --- a/app/services/clusters/aws/fetch_credentials_service.rb +++ b/app/services/clusters/aws/fetch_credentials_service.rb @@ -14,7 +14,7 @@ module Clusters end def execute - raise MissingRoleError.new('AWS provisioning role not configured') unless provision_role.present? + raise MissingRoleError, 'AWS provisioning role not configured' unless provision_role.present? ::Aws::AssumeRoleCredentials.new( client: client, @@ -54,7 +54,7 @@ module Clusters ## # If we haven't created a provider record yet, - # we restrict ourselves to read only access so + # we restrict ourselves to read-only access so # that we can safely expose credentials to the # frontend (to be used when populating the # creation form). diff --git a/app/services/clusters/integrations/create_service.rb b/app/services/clusters/integrations/create_service.rb index f9e9dd3e457..142f731a7d3 100644 --- a/app/services/clusters/integrations/create_service.rb +++ b/app/services/clusters/integrations/create_service.rb @@ -27,12 +27,15 @@ module Clusters private def integration - case params[:application_type] - when 'prometheus' - cluster.find_or_build_integration_prometheus - else - raise ArgumentError, "invalid application_type: #{params[:application_type]}" - end + @integration ||= \ + case params[:application_type] + when 'prometheus' + cluster.find_or_build_integration_prometheus + when 'elastic_stack' + cluster.find_or_build_integration_elastic_stack + else + raise ArgumentError, "invalid application_type: #{params[:application_type]}" + end end def authorized? diff --git a/app/services/clusters/management/create_project_service.rb b/app/services/clusters/management/create_project_service.rb deleted file mode 100644 index 5a0176edd12..00000000000 --- a/app/services/clusters/management/create_project_service.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Management - class CreateProjectService - CreateError = Class.new(StandardError) - - attr_reader :cluster, :current_user - - def initialize(cluster, current_user:) - @cluster = cluster - @current_user = current_user - end - - def execute - return unless management_project_required? - - project = create_management_project! - update_cluster!(project) - end - - private - - def management_project_required? - Feature.enabled?(:auto_create_cluster_management_project) && cluster.management_project.nil? - end - - def project_params - { - name: project_name, - description: project_description, - namespace_id: namespace.id, - visibility_level: Gitlab::VisibilityLevel::PRIVATE - } - end - - def project_name - "#{cluster.name} Cluster Management" - end - - def project_description - "This project is automatically generated and will be used to manage your Kubernetes cluster. [More information](#{docs_path})" - end - - def docs_path - Rails.application.routes.url_helpers.help_page_path('user/clusters/management_project') - end - - def create_management_project! - ::Projects::CreateService.new(current_user, project_params).execute.tap do |project| - errors = project.errors.full_messages - - if errors.any? - raise CreateError.new("Failed to create project: #{errors}") - end - end - end - - def update_cluster!(project) - unless cluster.update(management_project: project) - raise CreateError.new("Failed to update cluster: #{cluster.errors.full_messages}") - end - end - - def namespace - case cluster.cluster_type - when 'project_type' - cluster.project.namespace - when 'group_type' - cluster.group - when 'instance_type' - instance_administrators_group - else - raise NotImplementedError - end - end - - def instance_administrators_group - Gitlab::CurrentSettings.instance_administrators_group || - raise(CreateError.new('Instance administrators group not found')) - end - end - end -end diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index a1498da302e..fc18420f6e4 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -113,4 +113,4 @@ module Commits end end -Commits::CreateService.prepend_if_ee('EE::Commits::CreateService') +Commits::CreateService.prepend_mod_with('Commits::CreateService') diff --git a/app/services/concerns/alert_management/alert_processing.rb b/app/services/concerns/alert_management/alert_processing.rb index 7b6f681fe3e..98d255dec27 100644 --- a/app/services/concerns/alert_management/alert_processing.rb +++ b/app/services/concerns/alert_management/alert_processing.rb @@ -19,11 +19,7 @@ module AlertManagement # Updates or creates alert from payload for project # including system notes def process_alert - if alert.persisted? - process_existing_alert - else - process_new_alert - end + alert.persisted? ? process_existing_alert : process_new_alert end # Creates or closes issue for alert and notifies stakeholders @@ -33,22 +29,16 @@ module AlertManagement end def process_existing_alert - if resolving_alert? - process_resolved_alert - else - process_firing_alert - end + resolving_alert? ? process_resolved_alert : process_firing_alert end def process_resolved_alert SystemNoteService.log_resolving_alert(alert, alert_source) - return unless auto_close_incident? - if alert.resolve(incoming_payload.ends_at) SystemNoteService.change_alert_status(alert, User.alert_bot) - close_issue(alert.issue) + close_issue(alert.issue) if auto_close_incident? else logger.warn( message: 'Unable to update AlertManagement::Alert status to resolved', @@ -66,7 +56,7 @@ module AlertManagement return if issue.blank? || issue.closed? ::Issues::CloseService - .new(project, User.alert_bot) + .new(project: project, current_user: User.alert_bot) .execute(issue, system_note: false) SystemNoteService.auto_resolve_prometheus_alert(issue, project, User.alert_bot) if issue.reset.closed? @@ -76,6 +66,8 @@ module AlertManagement if alert.save alert.execute_services SystemNoteService.create_new_alert(alert, alert_source) + + process_resolved_alert if resolving_alert? else logger.warn( message: "Unable to create AlertManagement::Alert from #{alert_source}", @@ -88,7 +80,7 @@ module AlertManagement def process_incident_issues return if alert.issue || alert.resolved? - ::IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id) + ::IncidentManagement::ProcessAlertWorkerV2.perform_async(alert.id) end def send_alert_email @@ -128,7 +120,7 @@ module AlertManagement end def alert_source - alert.monitoring_tool + incoming_payload.monitoring_tool end def logger @@ -137,4 +129,4 @@ module AlertManagement end end -AlertManagement::AlertProcessing.prepend_ee_mod +AlertManagement::AlertProcessing.prepend_mod diff --git a/app/services/concerns/integrations/project_test_data.rb b/app/services/concerns/integrations/project_test_data.rb index 5968b90f8fe..acaa773fd49 100644 --- a/app/services/concerns/integrations/project_test_data.rb +++ b/app/services/concerns/integrations/project_test_data.rb @@ -63,7 +63,7 @@ module Integrations return { error: s_('TestHooks|Ensure the project has deployments.') } unless deployment.present? - Gitlab::DataBuilder::Deployment.build(deployment) + Gitlab::DataBuilder::Deployment.build(deployment, Time.current) end def releases_events_data diff --git a/app/services/concerns/measurable.rb b/app/services/concerns/measurable.rb index fcb3022a1dc..ebce8a0667a 100644 --- a/app/services/concerns/measurable.rb +++ b/app/services/concerns/measurable.rb @@ -23,7 +23,7 @@ # end # end # -# DummyService.prepend_if_ee('EE::DummyService') +# DummyService.prepend_mod_with('DummyService') # DummyService.prepend(Measurable) # ``` # diff --git a/app/services/concerns/services/return_service_responses.rb b/app/services/concerns/services/return_service_responses.rb new file mode 100644 index 00000000000..75432b76033 --- /dev/null +++ b/app/services/concerns/services/return_service_responses.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Services + # adapter for existing services over BaseServiceUtility - add error and + # success methods returning ServiceResponse objects + module ReturnServiceResponses + def error(message, http_status, pass_back: {}) + ServiceResponse.error(message: message, http_status: http_status, payload: pass_back) + end + + def success(payload) + ServiceResponse.success(payload: payload) + end + end +end diff --git a/app/services/container_expiration_policies/cleanup_service.rb b/app/services/container_expiration_policies/cleanup_service.rb index 69e5620d986..38a3fc231c6 100644 --- a/app/services/container_expiration_policies/cleanup_service.rb +++ b/app/services/container_expiration_policies/cleanup_service.rb @@ -13,13 +13,20 @@ module ContainerExpirationPolicies def execute return ServiceResponse.error(message: 'no repository') unless repository + unless policy.valid? + disable_policy! + + return ServiceResponse.error(message: 'invalid policy') + end + repository.start_expiration_policy! + schedule_next_run_if_needed begin service_result = Projects::ContainerRepository::CleanupTagsService .new(project, nil, policy_params.merge('container_expiration_policy' => true)) .execute(repository) - rescue + rescue StandardError repository.cleanup_unfinished! raise @@ -28,7 +35,6 @@ module ContainerExpirationPolicies if service_result[:status] == :success repository.update!( expiration_policy_cleanup_status: :cleanup_unscheduled, - expiration_policy_started_at: nil, expiration_policy_completed_at: Time.zone.now ) @@ -42,6 +48,27 @@ module ContainerExpirationPolicies private + def schedule_next_run_if_needed + return unless Feature.enabled?(:container_registry_expiration_policies_loopless) + return if policy.next_run_at.future? + + repos_before_next_run = ::ContainerRepository.for_project_id(policy.project_id) + .expiration_policy_started_at_nil_or_before(policy.next_run_at) + return if repos_before_next_run.exists? + + policy.schedule_next_run! + end + + def disable_policy! + policy.disable! + repository.cleanup_unscheduled! + + Gitlab::ErrorTracking.log_exception( + ::ContainerExpirationPolicyWorker::InvalidPolicyError.new, + container_expiration_policy_id: policy.id + ) + end + def success(cleanup_status, service_result) payload = { cleanup_status: cleanup_status, diff --git a/app/services/deploy_keys/create_service.rb b/app/services/deploy_keys/create_service.rb index 2dac94c7ade..3245e749164 100644 --- a/app/services/deploy_keys/create_service.rb +++ b/app/services/deploy_keys/create_service.rb @@ -8,4 +8,4 @@ module DeployKeys end end -DeployKeys::CreateService.prepend_if_ee('::EE::DeployKeys::CreateService') +DeployKeys::CreateService.prepend_mod_with('DeployKeys::CreateService') diff --git a/app/services/deployments/older_deployments_drop_service.rb b/app/services/deployments/older_deployments_drop_service.rb index 9283a5c1279..100d1267848 100644 --- a/app/services/deployments/older_deployments_drop_service.rb +++ b/app/services/deployments/older_deployments_drop_service.rb @@ -15,7 +15,7 @@ module Deployments Gitlab::OptimisticLocking.retry_lock(older_deployment.deployable, name: 'older_deployments_drop') do |deployable| deployable.drop(:forward_deployment_failure) end - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, subject_id: @deployment.id, deployment_id: older_deployment.id) end end diff --git a/app/services/deployments/update_environment_service.rb b/app/services/deployments/update_environment_service.rb index 98fedb9f699..9e862d6fa52 100644 --- a/app/services/deployments/update_environment_service.rb +++ b/app/services/deployments/update_environment_service.rb @@ -77,4 +77,4 @@ module Deployments end end -Deployments::UpdateEnvironmentService.prepend_if_ee('EE::Deployments::UpdateEnvironmentService') +Deployments::UpdateEnvironmentService.prepend_mod_with('Deployments::UpdateEnvironmentService') diff --git a/app/services/design_management/copy_design_collection/copy_service.rb b/app/services/design_management/copy_design_collection/copy_service.rb index c0b32e1e9ae..496103f9e58 100644 --- a/app/services/design_management/copy_design_collection/copy_service.rb +++ b/app/services/design_management/copy_design_collection/copy_service.rb @@ -47,7 +47,7 @@ module DesignManagement end ServiceResponse.success - rescue => error + rescue StandardError => error log_exception(error) target_design_collection.error_copy! diff --git a/app/services/design_management/delete_designs_service.rb b/app/services/design_management/delete_designs_service.rb index a90c34d4e34..7f76bcc5626 100644 --- a/app/services/design_management/delete_designs_service.rb +++ b/app/services/design_management/delete_designs_service.rb @@ -67,4 +67,4 @@ module DesignManagement end end -DesignManagement::DeleteDesignsService.prepend_if_ee('EE::DesignManagement::DeleteDesignsService') +DesignManagement::DeleteDesignsService.prepend_mod_with('DesignManagement::DeleteDesignsService') diff --git a/app/services/design_management/save_designs_service.rb b/app/services/design_management/save_designs_service.rb index c26d2e7ab47..44ebd45f76e 100644 --- a/app/services/design_management/save_designs_service.rb +++ b/app/services/design_management/save_designs_service.rb @@ -141,4 +141,4 @@ module DesignManagement end end -DesignManagement::SaveDesignsService.prepend_if_ee('EE::DesignManagement::SaveDesignsService') +DesignManagement::SaveDesignsService.prepend_mod_with('DesignManagement::SaveDesignsService') diff --git a/app/services/discussions/resolve_service.rb b/app/services/discussions/resolve_service.rb index 91c3cf136a4..3b733023eae 100644 --- a/app/services/discussions/resolve_service.rb +++ b/app/services/discussions/resolve_service.rb @@ -44,7 +44,7 @@ module Discussions Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter .track_resolve_thread_action(user: current_user) - MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) + MergeRequests::ResolvedDiscussionNotificationService.new(project: project, current_user: current_user).execute(merge_request) end SystemNoteService.discussion_continued_in_issue(discussion, project, current_user, follow_up_issue) if follow_up_issue diff --git a/app/services/draft_notes/publish_service.rb b/app/services/draft_notes/publish_service.rb index 82917241347..d73c3417a8b 100644 --- a/app/services/draft_notes/publish_service.rb +++ b/app/services/draft_notes/publish_service.rb @@ -24,7 +24,7 @@ module DraftNotes create_note_from_draft(draft) draft.delete - MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) + MergeRequests::ResolvedDiscussionNotificationService.new(project: project, current_user: current_user).execute(merge_request) end def publish_draft_notes @@ -41,7 +41,7 @@ module DraftNotes set_reviewed notification_service.async.new_review(review) - MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) + MergeRequests::ResolvedDiscussionNotificationService.new(project: project, current_user: current_user).execute(merge_request) end def create_note_from_draft(draft) @@ -68,7 +68,7 @@ module DraftNotes end def set_reviewed - ::MergeRequests::MarkReviewerReviewedService.new(project, current_user).execute(merge_request) + ::MergeRequests::MarkReviewerReviewedService.new(project: project, current_user: current_user).execute(merge_request) end end end diff --git a/app/services/emails/base_service.rb b/app/services/emails/base_service.rb index c94505b2068..58fc9799673 100644 --- a/app/services/emails/base_service.rb +++ b/app/services/emails/base_service.rb @@ -12,4 +12,4 @@ module Emails end end -Emails::BaseService.prepend_if_ee('::EE::Emails::BaseService') +Emails::BaseService.prepend_mod_with('Emails::BaseService') diff --git a/app/services/emails/create_service.rb b/app/services/emails/create_service.rb index 473256d9c6f..011978ba76a 100644 --- a/app/services/emails/create_service.rb +++ b/app/services/emails/create_service.rb @@ -12,4 +12,4 @@ module Emails end end -Emails::CreateService.prepend_if_ee('EE::Emails::CreateService') +Emails::CreateService.prepend_mod_with('Emails::CreateService') diff --git a/app/services/emails/destroy_service.rb b/app/services/emails/destroy_service.rb index 6e671f52d57..288bee84ef7 100644 --- a/app/services/emails/destroy_service.rb +++ b/app/services/emails/destroy_service.rb @@ -18,4 +18,4 @@ module Emails end end -Emails::DestroyService.prepend_if_ee('EE::Emails::DestroyService') +Emails::DestroyService.prepend_mod_with('Emails::DestroyService') diff --git a/app/services/error_tracking/issue_update_service.rb b/app/services/error_tracking/issue_update_service.rb index b8235678d1d..2f8bbfddef0 100644 --- a/app/services/error_tracking/issue_update_service.rb +++ b/app/services/error_tracking/issue_update_service.rb @@ -35,7 +35,7 @@ module ErrorTracking def close_issue(issue) Issues::CloseService - .new(project, current_user) + .new(project: project, current_user: current_user) .execute(issue, system_note: false) end diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index 85658598afc..01a40fc6473 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -222,4 +222,4 @@ class EventCreateService end end -EventCreateService.prepend_if_ee('EE::EventCreateService') +EventCreateService.prepend_mod_with('EventCreateService') diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index 825faf59c13..a49b981c680 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -96,7 +96,6 @@ module Git def track_ci_config_change_event return unless Gitlab::CurrentSettings.usage_ping_enabled? - return unless ::Feature.enabled?(:usage_data_unique_users_committing_ciconfigfile, project, default_enabled: :yaml) return unless default_branch? commits_changing_ci_config.each do |commit| @@ -227,4 +226,4 @@ module Git end end -Git::BranchHooksService.prepend_if_ee('::EE::Git::BranchHooksService') +Git::BranchHooksService.prepend_mod_with('Git::BranchHooksService') diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb index d250bca7bf2..5dcc2de456c 100644 --- a/app/services/git/branch_push_service.rb +++ b/app/services/git/branch_push_service.rb @@ -94,4 +94,4 @@ module Git end end -Git::BranchPushService.prepend_if_ee('::EE::Git::BranchPushService') +Git::BranchPushService.prepend_mod_with('Git::BranchPushService') diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb index d039ed00efc..6f348ff9e0b 100644 --- a/app/services/git/process_ref_changes_service.rb +++ b/app/services/git/process_ref_changes_service.rb @@ -77,7 +77,7 @@ module Git def merge_request_branches_for(ref_type, changes) return [] if ref_type == :tag - MergeRequests::PushedBranchesService.new(project, current_user, changes: changes).execute + MergeRequests::PushedBranchesService.new(project: project, current_user: current_user, params: { changes: changes }).execute end end end diff --git a/app/services/git/tag_hooks_service.rb b/app/services/git/tag_hooks_service.rb index 0e5e1bbc992..d83924fec28 100644 --- a/app/services/git/tag_hooks_service.rb +++ b/app/services/git/tag_hooks_service.rb @@ -35,4 +35,4 @@ module Git end end -Git::TagHooksService.prepend_if_ee('::EE::Git::TagHooksService') +Git::TagHooksService.prepend_mod_with('Git::TagHooksService') diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb index 82958abfe6e..11aeb650a5b 100644 --- a/app/services/git/wiki_push_service.rb +++ b/app/services/git/wiki_push_service.rb @@ -86,4 +86,4 @@ module Git end end -Git::WikiPushService.prepend_if_ee('EE::Git::WikiPushService') +Git::WikiPushService.prepend_mod_with('Git::WikiPushService') diff --git a/app/services/groups/autocomplete_service.rb b/app/services/groups/autocomplete_service.rb new file mode 100644 index 00000000000..92b05d9ac08 --- /dev/null +++ b/app/services/groups/autocomplete_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Groups + class AutocompleteService < Groups::BaseService + include LabelsAsHash + + # rubocop: disable CodeReuse/ActiveRecord + def issues(confidential_only: false, issue_types: nil) + finder_params = { group_id: group.id, include_subgroups: true, state: 'opened' } + finder_params[:confidential] = true if confidential_only.present? + finder_params[:issue_types] = issue_types if issue_types.present? + + IssuesFinder.new(current_user, finder_params) + .execute + .preload(project: :namespace) + .select(:iid, :title, :project_id) + end + # rubocop: enable CodeReuse/ActiveRecord + + # rubocop: disable CodeReuse/ActiveRecord + def merge_requests + MergeRequestsFinder.new(current_user, group_id: group.id, include_subgroups: true, state: 'opened') + .execute + .preload(target_project: :namespace) + .select(:iid, :title, :target_project_id) + end + # rubocop: enable CodeReuse/ActiveRecord + + # rubocop: disable CodeReuse/ActiveRecord + def milestones + group_ids = group.self_and_ancestors.public_or_visible_to_user(current_user).pluck(:id) + + MilestonesFinder.new(group_ids: group_ids).execute.select(:iid, :title, :due_date) + end + # rubocop: enable CodeReuse/ActiveRecord + + def labels_as_hash(target) + super(target, group_id: group.id, only_group_labels: true, include_ancestor_groups: true) + end + + def commands(noteable) + return [] unless noteable + + QuickActions::InterpretService.new(nil, current_user).available_commands(noteable) + end + end +end + +Groups::AutocompleteService.prepend_mod diff --git a/app/services/groups/count_service.rb b/app/services/groups/count_service.rb index 2a15ae3bc57..735acddb025 100644 --- a/app/services/groups/count_service.rb +++ b/app/services/groups/count_service.rb @@ -19,13 +19,26 @@ module Groups cached_count = Rails.cache.read(cache_key) return cached_count unless cached_count.blank? - refreshed_count = uncached_count - update_cache_for_key(cache_key) { refreshed_count } if refreshed_count > CACHED_COUNT_THRESHOLD - refreshed_count + refresh_cache_over_threshold end - def cache_key - ['groups', "#{issuable_key}_count_service", VERSION, group.id, cache_key_name] + def refresh_cache_over_threshold(key = nil) + key ||= cache_key + new_count = uncached_count + + if new_count > CACHED_COUNT_THRESHOLD + update_cache_for_key(key) { new_count } + else + delete_cache + end + + new_count + end + + def cache_key(key_name = nil) + key_name ||= cache_key_name + + ['groups', "#{issuable_key}_count_service", VERSION, group.id, key_name] end private diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 9ddb8ae7695..8e8efe7d555 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -37,7 +37,7 @@ module Groups Group.transaction do if @group.save @group.add_owner(current_user) - Service.create_from_active_default_integrations(@group, :group_id) + Integration.create_from_active_default_integrations(@group, :group_id) OnboardingProgress.onboard(@group) end end @@ -103,4 +103,4 @@ module Groups end end -Groups::CreateService.prepend_if_ee('EE::Groups::CreateService') +Groups::CreateService.prepend_mod_with('Groups::CreateService') diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb index a27330d1104..08c4e0231e7 100644 --- a/app/services/groups/destroy_service.rb +++ b/app/services/groups/destroy_service.rb @@ -58,4 +58,4 @@ module Groups end end -Groups::DestroyService.prepend_if_ee('EE::Groups::DestroyService') +Groups::DestroyService.prepend_mod_with('Groups::DestroyService') diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb index a436aec1b39..ea26ebec20b 100644 --- a/app/services/groups/import_export/export_service.rb +++ b/app/services/groups/import_export/export_service.rb @@ -96,7 +96,7 @@ module Groups def notify_error! notify_error - raise Gitlab::ImportExport::Error.new(shared.errors.to_sentence) + raise Gitlab::ImportExport::Error, shared.errors.to_sentence end def notify_success @@ -127,4 +127,4 @@ module Groups end end -Groups::ImportExport::ExportService.prepend_if_ee('EE::Groups::ImportExport::ExportService') +Groups::ImportExport::ExportService.prepend_mod_with('Groups::ImportExport::ExportService') diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb index bf3f09f22d4..f9db552f743 100644 --- a/app/services/groups/import_export/import_service.rb +++ b/app/services/groups/import_export/import_service.rb @@ -114,7 +114,7 @@ module Groups def notify_error! notify_error - raise Gitlab::ImportExport::Error.new(shared.errors.to_sentence) + raise Gitlab::ImportExport::Error, shared.errors.to_sentence end def remove_base_tmp_dir @@ -124,4 +124,4 @@ module Groups end end -Groups::ImportExport::ImportService.prepend_if_ee('EE::Groups::ImportExport::ImportService') +Groups::ImportExport::ImportService.prepend_mod_with('Groups::ImportExport::ImportService') diff --git a/app/services/groups/open_issues_count_service.rb b/app/services/groups/open_issues_count_service.rb index ef787a04315..17cf3d38987 100644 --- a/app/services/groups/open_issues_count_service.rb +++ b/app/services/groups/open_issues_count_service.rb @@ -6,6 +6,12 @@ module Groups PUBLIC_COUNT_KEY = 'group_public_open_issues_count' TOTAL_COUNT_KEY = 'group_total_open_issues_count' + def clear_all_cache_keys + [cache_key(PUBLIC_COUNT_KEY), cache_key(TOTAL_COUNT_KEY)].each do |key| + Rails.cache.delete(key) + end + end + private def cache_key_name @@ -23,7 +29,14 @@ module Groups end def relation_for_count - IssuesFinder.new(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true, public_only: public_only?).execute + IssuesFinder.new( + user, + group_id: group.id, + state: 'opened', + non_archived: true, + include_subgroups: true, + public_only: public_only? + ).execute end def issuable_key diff --git a/app/services/groups/participants_service.rb b/app/services/groups/participants_service.rb new file mode 100644 index 00000000000..0844c98dd6a --- /dev/null +++ b/app/services/groups/participants_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Groups + class ParticipantsService < Groups::BaseService + include Users::ParticipableService + + def execute(noteable) + @noteable = noteable + + participants = + noteable_owner + + participants_in_noteable + + all_members + + groups + + group_members + + render_participants_as_hash(participants.uniq) + end + + def all_members + count = group_members.count + [{ username: "all", name: "All Group Members", count: count }] + end + + def group_members + return [] unless noteable + + @group_members ||= sorted(noteable.group.direct_and_indirect_users) + end + end +end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index e800e546a45..56ff1310def 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -200,16 +200,16 @@ module Groups end def update_integrations - @group.services.inherit.delete_all - Service.create_from_active_default_integrations(@group, :group_id) + @group.integrations.inherit.delete_all + Integration.create_from_active_default_integrations(@group, :group_id) end def propagate_integrations - @group.services.inherit.each do |integration| + @group.integrations.inherit.each do |integration| PropagateIntegrationWorker.perform_async(integration.id) end end end end -Groups::TransferService.prepend_if_ee('EE::Groups::TransferService') +Groups::TransferService.prepend_mod_with('Groups::TransferService') diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index ff369d01efc..1ad43b051be 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -147,4 +147,4 @@ module Groups end end -Groups::UpdateService.prepend_if_ee('EE::Groups::UpdateService') +Groups::UpdateService.prepend_mod_with('Groups::UpdateService') diff --git a/app/services/ide/schemas_config_service.rb b/app/services/ide/schemas_config_service.rb index 8d2ce97103d..a013a4679b5 100644 --- a/app/services/ide/schemas_config_service.rb +++ b/app/services/ide/schemas_config_service.rb @@ -10,7 +10,7 @@ module Ide def execute schema = predefined_schema_for(params[:filename]) || {} success(schema: schema) - rescue => e + rescue StandardError => e error(e.message) end @@ -46,4 +46,4 @@ module Ide end end -Ide::SchemasConfigService.prepend_if_ee('::EE::Ide::SchemasConfigService') +Ide::SchemasConfigService.prepend_mod_with('Ide::SchemasConfigService') diff --git a/app/services/import/base_service.rb b/app/services/import/base_service.rb index 2683c75e41f..4a43b2f7425 100644 --- a/app/services/import/base_service.rb +++ b/app/services/import/base_service.rb @@ -18,7 +18,7 @@ module Import group = Groups::NestedCreateService.new(current_user, group_path: namespace).execute group.errors.any? ? current_user.namespace : group - rescue => e + rescue StandardError => e Gitlab::AppLogger.error(e) current_user.namespace diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index 3ee5a185f42..2f808d45ffd 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -122,4 +122,4 @@ module Import end end -Import::GithubService.prepend_if_ee('EE::Import::GithubService') +Import::GithubService.prepend_mod_with('Import::GithubService') diff --git a/app/services/import/gitlab_projects/create_project_from_remote_file_service.rb b/app/services/import/gitlab_projects/create_project_from_remote_file_service.rb new file mode 100644 index 00000000000..bbfdaf692f9 --- /dev/null +++ b/app/services/import/gitlab_projects/create_project_from_remote_file_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Import + module GitlabProjects + class CreateProjectFromRemoteFileService < CreateProjectFromUploadedFileService + FILE_SIZE_LIMIT = 10.gigabytes + ALLOWED_CONTENT_TYPES = ['application/gzip'].freeze + + validate :valid_remote_import_url? + validate :validate_file_size + validate :validate_content_type + + private + + def required_params + [:path, :namespace, :remote_import_url] + end + + def project_params + super + .except(:file) + .merge(import_export_upload: ::ImportExportUpload.new( + remote_import_url: params[:remote_import_url] + )) + end + + def valid_remote_import_url? + ::Gitlab::UrlBlocker.validate!( + params[:remote_import_url], + allow_localhost: allow_local_requests?, + allow_local_network: allow_local_requests?, + schemes: %w(http https) + ) + + true + rescue ::Gitlab::UrlBlocker::BlockedUrlError => e + errors.add(:base, e.message) + + false + end + + def allow_local_requests? + ::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services? + end + + def validate_content_type + if headers['content-type'].blank? + errors.add(:base, "Missing 'ContentType' header") + elsif !ALLOWED_CONTENT_TYPES.include?(headers['content-type']) + errors.add(:base, "Remote file content type '%{content_type}' not allowed. (Allowed content types: %{allowed})" % { + content_type: headers['content-type'], + allowed: ALLOWED_CONTENT_TYPES.join(',') + }) + end + end + + def validate_file_size + if headers['content-length'].to_i == 0 + errors.add(:base, "Missing 'ContentLength' header") + elsif headers['content-length'].to_i > FILE_SIZE_LIMIT + errors.add(:base, 'Remote file larger than limit. (limit %{limit})' % { + limit: ActiveSupport::NumberHelper.number_to_human_size(FILE_SIZE_LIMIT) + }) + end + end + + def headers + return {} if params[:remote_import_url].blank? || !valid_remote_import_url? + + @headers ||= Gitlab::HTTP.head(params[:remote_import_url]).headers + end + end + end +end diff --git a/app/services/import/gitlab_projects/create_project_from_uploaded_file_service.rb b/app/services/import/gitlab_projects/create_project_from_uploaded_file_service.rb new file mode 100644 index 00000000000..35d52a11288 --- /dev/null +++ b/app/services/import/gitlab_projects/create_project_from_uploaded_file_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Import + module GitlabProjects + class CreateProjectFromUploadedFileService + include ActiveModel::Validations + include ::Services::ReturnServiceResponses + + validate :required_params_presence + + def initialize(current_user, params = {}) + @current_user = current_user + @params = params.dup + end + + def execute + return error(errors.full_messages.first) unless valid? + return error(project.errors.full_messages&.first) unless project.saved? + + success(project) + rescue StandardError => e + error(e.message) + end + + private + + attr_reader :current_user, :params + + def error(message) + super(message, :bad_request) + end + + def project + @project ||= ::Projects::GitlabProjectsImportService.new( + current_user, + project_params, + params[:override] + ).execute + end + + def project_params + { + name: params[:name], + path: params[:path], + namespace_id: params[:namespace].id, + file: params[:file], + overwrite: params[:overwrite], + import_type: 'gitlab_project' + } + end + + def required_params + [:path, :namespace, :file] + end + + def required_params_presence + required_params + .select { |key| params[key].blank? } + .each do |missing_parameter| + errors.add(:base, "Parameter '#{missing_parameter}' is required") + end + end + end + end +end diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb index cff288d602b..7497ee00d74 100644 --- a/app/services/incident_management/incidents/create_service.rb +++ b/app/services/incident_management/incidents/create_service.rb @@ -15,11 +15,13 @@ module IncidentManagement def execute issue = Issues::CreateService.new( - project, - current_user, - title: title, - description: description, - issue_type: ISSUE_TYPE + project: project, + current_user: current_user, + params: { + title: title, + description: description, + issue_type: ISSUE_TYPE + } ).execute return error(issue.errors.full_messages.to_sentence, issue) unless issue.valid? diff --git a/app/services/integrations/test/project_service.rb b/app/services/integrations/test/project_service.rb index d72ca928c34..31c8f02c7b6 100644 --- a/app/services/integrations/test/project_service.rb +++ b/app/services/integrations/test/project_service.rb @@ -42,4 +42,4 @@ module Integrations end end -Integrations::Test::ProjectService.prepend_if_ee('::EE::Integrations::Test::ProjectService') +Integrations::Test::ProjectService.prepend_mod_with('Integrations::Test::ProjectService') diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index 8bcbb92cd0e..cd32cd78728 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -15,9 +15,13 @@ module Issuable def execute(type) ids = params.delete(:issuable_ids).split(",") set_update_params(type) - items = update_issuables(type, ids) + updated_issuables = update_issuables(type, ids) - response_success(payload: { count: items.size }) + if updated_issuables.present? && requires_count_cache_reset?(type) + schedule_group_issues_count_reset(updated_issuables) + end + + response_success(payload: { count: updated_issuables.size }) rescue ArgumentError => e response_error(e.message, 422) end @@ -53,7 +57,7 @@ module Issuable items.each do |issuable| next unless can?(current_user, :"update_#{type}", issuable) - update_class.new(issuable.issuing_parent, current_user, params).execute(issuable) + update_class.new(**update_class.constructor_container_arg(issuable.issuing_parent), current_user: current_user, params: params).execute(issuable) end items @@ -81,7 +85,18 @@ module Issuable def response_error(message, http_status) ServiceResponse.error(message: message, http_status: http_status) end + + def requires_count_cache_reset?(type) + type.to_sym == :issue && params.include?(:state_event) + end + + def schedule_group_issues_count_reset(updated_issuables) + group_ids = updated_issuables.map(&:project).map(&:namespace_id) + return if group_ids.empty? + + Issuables::ClearGroupsIssueCounterWorker.perform_async(group_ids) + end end end -Issuable::BulkUpdateService.prepend_if_ee('EE::Issuable::BulkUpdateService') +Issuable::BulkUpdateService.prepend_mod_with('Issuable::BulkUpdateService') diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb index 3861d88bce9..e1b4613726d 100644 --- a/app/services/issuable/clone/attributes_rewriter.rb +++ b/app/services/issuable/clone/attributes_rewriter.rb @@ -73,12 +73,17 @@ module Issuable copy_events(ResourceStateEvent.table_name, original_entity.resource_state_events) do |event| event.attributes - .except('id') + .except(*blocked_state_event_attributes) .merge(entity_key => new_entity.id, 'state' => ResourceStateEvent.states[event.state]) end end + # Overriden on EE::Issuable::Clone::AttributesRewriter + def blocked_state_event_attributes + ['id'] + end + def event_attributes_with_milestone(event, milestone) event.attributes .except('id') @@ -118,4 +123,4 @@ module Issuable end end -Issuable::Clone::AttributesRewriter.prepend_if_ee('EE::Issuable::Clone::AttributesRewriter') +Issuable::Clone::AttributesRewriter.prepend_mod_with('Issuable::Clone::AttributesRewriter') diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb index 3c2bc527b12..f8a9eb3ece5 100644 --- a/app/services/issuable/clone/base_service.rb +++ b/app/services/issuable/clone/base_service.rb @@ -65,7 +65,7 @@ module Issuable end def close_issue - close_service = Issues::CloseService.new(old_project, current_user) + close_service = Issues::CloseService.new(project: old_project, current_user: current_user) close_service.execute(original_entity, notifications: false, system_note: false) end @@ -88,4 +88,4 @@ module Issuable end end -Issuable::Clone::BaseService.prepend_if_ee('EE::Issuable::Clone::BaseService') +Issuable::Clone::BaseService.prepend_mod_with('Issuable::Clone::BaseService') diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index fd2dc3787c2..aedd0c377c6 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Issuable - class CommonSystemNotesService < ::BaseService + class CommonSystemNotesService < ::BaseProjectService attr_reader :issuable def execute(issuable, old_labels: [], old_milestone: nil, is_update: true) @@ -109,4 +109,4 @@ module Issuable end end -Issuable::CommonSystemNotesService.prepend_if_ee('EE::Issuable::CommonSystemNotesService') +Issuable::CommonSystemNotesService.prepend_mod_with('Issuable::CommonSystemNotesService') diff --git a/app/services/issuable/destroy_label_links_service.rb b/app/services/issuable/destroy_label_links_service.rb new file mode 100644 index 00000000000..6fff9b5e8d2 --- /dev/null +++ b/app/services/issuable/destroy_label_links_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Issuable + class DestroyLabelLinksService + BATCH_SIZE = 100 + + def initialize(target_id, target_type) + @target_id = target_id + @target_type = target_type + end + + def execute + inner_query = + LabelLink + .select(:id) + .for_target(target_id, target_type) + .limit(BATCH_SIZE) + + delete_query = <<~SQL + DELETE FROM "#{LabelLink.table_name}" + WHERE id IN (#{inner_query.to_sql}) + SQL + + loop do + result = ActiveRecord::Base.connection.execute(delete_query) + + break if result.cmd_tuples == 0 + end + end + + private + + attr_reader :target_id, :target_type + end +end diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb index d5aa84d8d6c..b75905fb5b0 100644 --- a/app/services/issuable/destroy_service.rb +++ b/app/services/issuable/destroy_service.rb @@ -3,15 +3,13 @@ module Issuable class DestroyService < IssuableBaseService def execute(issuable) - if issuable.destroy - after_destroy(issuable) - end + after_destroy(issuable) if issuable.destroy end private def after_destroy(issuable) - delete_todos(issuable) + delete_associated_records(issuable) issuable.update_project_counter_caches issuable.assignees.each(&:invalidate_cache_counts) end @@ -20,19 +18,23 @@ module Issuable issuable.resource_parent.group end - def delete_todos(issuable) + def delete_associated_records(issuable) actor = group_for(issuable) - if Feature.enabled?(:destroy_issuable_todos_async, actor, default_enabled: :yaml) - TodosDestroyer::DestroyedIssuableWorker - .perform_async(issuable.id, issuable.class.name) - else - TodosDestroyer::DestroyedIssuableWorker - .new - .perform(issuable.id, issuable.class.name) - end + delete_todos(actor, issuable) + delete_label_links(actor, issuable) + end + + def delete_todos(actor, issuable) + TodosDestroyer::DestroyedIssuableWorker + .perform_async(issuable.id, issuable.class.name) + end + + def delete_label_links(actor, issuable) + Issuable::LabelLinksDestroyWorker + .perform_async(issuable.id, issuable.class.name) end end end -Issuable::DestroyService.prepend_if_ee('EE::Issuable::DestroyService') +Issuable::DestroyService.prepend_mod_with('Issuable::DestroyService') diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb index 5a2665285de..27dbc8b3cc4 100644 --- a/app/services/issuable/import_csv/base_service.rb +++ b/app/services/issuable/import_csv/base_service.rb @@ -68,7 +68,7 @@ module Issuable end def create_issuable(attributes) - create_issuable_class.new(@project, @user, attributes).execute + create_issuable_class.new(project: @project, current_user: @user, params: attributes).execute end def email_results_to_user diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index add53bc6267..099e0d81bc9 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -1,11 +1,21 @@ # frozen_string_literal: true -class IssuableBaseService < BaseService +class IssuableBaseService < ::BaseProjectService private + def self.constructor_container_arg(value) + # TODO: Dynamically determining the type of a constructor arg based on the class is an antipattern, + # but the root cause is that Epics::BaseService has some issues that inheritance may not be the + # appropriate pattern. See more details in comments at the top of Epics::BaseService#initialize. + # Follow on issue to address this: + # https://gitlab.com/gitlab-org/gitlab/-/issues/328438 + + { project: value } + end + attr_accessor :params, :skip_milestone_email - def initialize(project, user = nil, params = {}) + def initialize(project:, current_user: nil, params: {}) super @skip_milestone_email = @params.delete(:skip_milestone_email) @@ -343,9 +353,13 @@ class IssuableBaseService < BaseService def change_state(issuable) case params.delete(:state_event) when 'reopen' - reopen_service.new(project, current_user, {}).execute(issuable) + service_class = reopen_service when 'close' - close_service.new(project, current_user, {}).execute(issuable) + service_class = close_service + end + + if service_class + service_class.new(**service_class.constructor_container_arg(project), current_user: current_user).execute(issuable) end end @@ -406,7 +420,7 @@ class IssuableBaseService < BaseService end def create_system_notes(issuable, **options) - Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, **options) + Issuable::CommonSystemNotesService.new(project: project, current_user: current_user).execute(issuable, **options) end def associations_before_update(issuable) @@ -493,4 +507,4 @@ class IssuableBaseService < BaseService end end -IssuableBaseService.prepend_if_ee('EE::IssuableBaseService') +IssuableBaseService.prepend_mod_with('IssuableBaseService') diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb index cbb81f1f521..81685f81afa 100644 --- a/app/services/issuable_links/create_service.rb +++ b/app/services/issuable_links/create_service.rb @@ -118,4 +118,4 @@ module IssuableLinks end end -IssuableLinks::CreateService.prepend_if_ee('EE::IssuableLinks::CreateService') +IssuableLinks::CreateService.prepend_mod_with('IssuableLinks::CreateService') diff --git a/app/services/issue_links/create_service.rb b/app/services/issue_links/create_service.rb index 63762b1af79..a022d3e0bcf 100644 --- a/app/services/issue_links/create_service.rb +++ b/app/services/issue_links/create_service.rb @@ -43,4 +43,4 @@ module IssueLinks end end -IssueLinks::CreateService.prepend_if_ee('EE::IssueLinks::CreateService') +IssueLinks::CreateService.prepend_mod_with('IssueLinks::CreateService') diff --git a/app/services/issue_rebalancing_service.rb b/app/services/issue_rebalancing_service.rb index f9c3388204f..6a8d45b92b2 100644 --- a/app/services/issue_rebalancing_service.rb +++ b/app/services/issue_rebalancing_service.rb @@ -3,8 +3,18 @@ class IssueRebalancingService MAX_ISSUE_COUNT = 10_000 BATCH_SIZE = 100 + SMALLEST_BATCH_SIZE = 5 + RETRIES_LIMIT = 3 TooManyIssues = Class.new(StandardError) + TIMING_CONFIGURATION = [ + [0.1.seconds, 0.05.seconds], # short timings, lock_timeout: 100ms, sleep after LockWaitTimeout: 50ms + [0.5.seconds, 0.05.seconds], + [1.second, 0.5.seconds], + [1.second, 0.5.seconds], + [5.seconds, 1.second] + ].freeze + def initialize(issue) @issue = issue @base = Issue.relative_positioning_query_base(issue) @@ -23,14 +33,23 @@ class IssueRebalancingService assign_positions(start, indexed_ids) .sort_by(&:first) .each_slice(BATCH_SIZE) do |pairs_with_position| - update_positions(pairs_with_position, 'rebalance issue positions in batches ordered by id') + if Feature.enabled?(:issue_rebalancing_with_retry) + update_positions_with_retry(pairs_with_position, 'rebalance issue positions in batches ordered by id') + else + update_positions(pairs_with_position, 'rebalance issue positions in batches ordered by id') + end end end else Issue.transaction do indexed_ids.each_slice(BATCH_SIZE) do |pairs| pairs_with_position = assign_positions(start, pairs) - update_positions(pairs_with_position, 'rebalance issue positions') + + if Feature.enabled?(:issue_rebalancing_with_retry) + update_positions_with_retry(pairs_with_position, 'rebalance issue positions') + else + update_positions(pairs_with_position, 'rebalance issue positions') + end end end end @@ -52,12 +71,37 @@ class IssueRebalancingService end end + def update_positions_with_retry(pairs_with_position, query_name) + retries = 0 + batch_size = pairs_with_position.size + + until pairs_with_position.empty? + begin + update_positions(pairs_with_position.first(batch_size), query_name) + pairs_with_position = pairs_with_position.drop(batch_size) + retries = 0 + rescue ActiveRecord::StatementTimeout, ActiveRecord::QueryCanceled => ex + raise ex if batch_size < SMALLEST_BATCH_SIZE + + if (retries += 1) == RETRIES_LIMIT + # shrink the batch size in half when RETRIES limit is reached and update still fails perhaps because batch size is still too big + batch_size = (batch_size / 2).to_i + retries = 0 + end + + retry + end + end + end + def update_positions(pairs_with_position, query_name) values = pairs_with_position.map do |id, index| "(#{id}, #{index})" end.join(', ') - run_update_query(values, query_name) + Gitlab::Database::WithLockRetries.new(timing_configuration: TIMING_CONFIGURATION, klass: self.class).run do + run_update_query(values, query_name) + end end def run_update_query(values, query_name) diff --git a/app/services/issues/after_create_service.rb b/app/services/issues/after_create_service.rb index 0c6ec65f0e2..5d10eca2979 100644 --- a/app/services/issues/after_create_service.rb +++ b/app/services/issues/after_create_service.rb @@ -10,4 +10,4 @@ module Issues end end -Issues::AfterCreateService.prepend_ee_mod +Issues::AfterCreateService.prepend_mod diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 07e4a10708e..72e906e20f1 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -37,6 +37,8 @@ module Issues def filter_params(issue) super + params.delete(:issue_type) unless issue_type_allowed?(issue) + moved_issue = params.delete(:moved_issue) # Setting created_at, updated_at and iid is allowed only for admins and owners or @@ -75,7 +77,12 @@ module Issues Milestones::IssuesCountService.new(milestone).delete_cache end + + # @param object [Issue, Project] + def issue_type_allowed?(object) + can?(current_user, :"create_#{params[:issue_type]}", object) + end end end -Issues::BaseService.prepend_if_ee('EE::Issues::BaseService') +Issues::BaseService.prepend_mod_with('Issues::BaseService') diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 3145739fe91..5cb138946d7 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -64,20 +64,17 @@ module Issues private - def allowed_issue_base_params - [:title, :description, :confidential, :issue_type] - end + def allowed_issue_params + allowed_params = [ + :title, + :description, + :confidential + ] - def allowed_issue_admin_params - [:milestone_id] - end + allowed_params << :milestone_id if can?(current_user, :admin_issue, project) + allowed_params << :issue_type if issue_type_allowed?(project) - def allowed_issue_params - if can?(current_user, :admin_issue, project) - params.slice(*(allowed_issue_base_params + allowed_issue_admin_params)) - else - params.slice(*allowed_issue_base_params) - end + params.slice(*allowed_params) end def build_issue_params @@ -88,4 +85,4 @@ module Issues end end -Issues::BuildService.prepend_if_ee('EE::Issues::BuildService') +Issues::BuildService.prepend_mod_with('Issues::BuildService') diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb index b64e4687a87..6df32f1104c 100644 --- a/app/services/issues/clone_service.rb +++ b/app/services/issues/clone_service.rb @@ -57,7 +57,7 @@ module Issues # Skip creation of system notes for existing attributes of the issue. The system notes of the old # issue are copied over so we don't want to end up with duplicate notes. - CreateService.new(target_project, current_user, new_params).execute(skip_system_notes: true) + CreateService.new(project: target_project, current_user: current_user, params: new_params).execute(skip_system_notes: true) end def queue_copy_designs @@ -90,4 +90,4 @@ module Issues end end -Issues::CloneService.prepend_if_ee('EE::Issues::CloneService') +Issues::CloneService.prepend_mod_with('Issues::CloneService') diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 746f7d1f4c1..1700d1d8586 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -24,8 +24,8 @@ module Issues return issue end - if project.issues_enabled? && issue.close - issue.update(closed_by: current_user) + if project.issues_enabled? && issue.close(current_user) + remove_on_close_labels_from(issue) event_service.close_issue(issue, current_user) create_note(issue, closed_via) if system_note @@ -52,6 +52,18 @@ module Issues private + def remove_on_close_labels_from(issue) + old_labels = issue.labels.to_a + + issue.label_links.with_remove_on_close_labels.delete_all + issue.labels.reset + + Issuable::CommonSystemNotesService.new(project: project, current_user: current_user).execute( + issue, + old_labels: old_labels + ) + end + def close_external_issue(issue, closed_via) return unless project.external_issue_tracker&.support_close_issue? diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 68660b35bee..1f4efeb1a8a 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -8,7 +8,7 @@ module Issues @request = params.delete(:request) @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request) - @issue = BuildService.new(project, current_user, params).execute + @issue = BuildService.new(project: project, current_user: current_user, params: params).execute filter_resolve_discussion_params @@ -75,4 +75,4 @@ module Issues end end -Issues::CreateService.prepend_ee_mod +Issues::CreateService.prepend_mod diff --git a/app/services/issues/duplicate_service.rb b/app/services/issues/duplicate_service.rb index feb496542c8..d150f0e5917 100644 --- a/app/services/issues/duplicate_service.rb +++ b/app/services/issues/duplicate_service.rb @@ -10,7 +10,7 @@ module Issues create_issue_duplicate_note(duplicate_issue, canonical_issue) create_issue_canonical_note(canonical_issue, duplicate_issue) - close_service.new(project, current_user, {}).execute(duplicate_issue) + close_service.new(project: project, current_user: current_user).execute(duplicate_issue) duplicate_issue.update(duplicated_to: canonical_issue) relate_two_issues(duplicate_issue, canonical_issue) diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb index dd43c77adfa..3809d8bc347 100644 --- a/app/services/issues/export_csv_service.rb +++ b/app/services/issues/export_csv_service.rb @@ -58,4 +58,4 @@ module Issues end end -Issues::ExportCsvService.prepend_if_ee('EE::Issues::ExportCsvService') +Issues::ExportCsvService.prepend_mod_with('Issues::ExportCsvService') diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index c1afb8f456d..e49123a2993 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -61,7 +61,7 @@ module Issues # Skip creation of system notes for existing attributes of the issue. The system notes of the old # issue are copied over so we don't want to end up with duplicate notes. - CreateService.new(@target_project, @current_user, new_params).execute(skip_system_notes: true) + CreateService.new(project: @target_project, current_user: @current_user, params: new_params).execute(skip_system_notes: true) end def queue_copy_designs @@ -106,4 +106,4 @@ module Issues end end -Issues::MoveService.prepend_if_ee('EE::Issues::MoveService') +Issues::MoveService.prepend_mod_with('Issues::MoveService') diff --git a/app/services/issues/related_branches_service.rb b/app/services/issues/related_branches_service.rb index 98d8412102f..8b08c1f8ddb 100644 --- a/app/services/issues/related_branches_service.rb +++ b/app/services/issues/related_branches_service.rb @@ -30,7 +30,7 @@ module Issues def branches_with_merge_request_for(issue) Issues::ReferencedMergeRequestsService - .new(project, current_user) + .new(project: project, current_user: current_user) .referenced_merge_requests(issue) .map(&:source_branch) end diff --git a/app/services/issues/reorder_service.rb b/app/services/issues/reorder_service.rb index c82ad6ea501..9c5fbec7d8e 100644 --- a/app/services/issues/reorder_service.rb +++ b/app/services/issues/reorder_service.rb @@ -21,7 +21,7 @@ module Issues end def update(issue, attrs) - ::Issues::UpdateService.new(project, current_user, attrs).execute(issue) + ::Issues::UpdateService.new(project: project, current_user: current_user, params: attrs).execute(issue) rescue ActiveRecord::RecordNotFound false end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 702527d80a7..af5029f8364 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -24,7 +24,7 @@ module Issues def filter_params(issue) super - # filter confidential in `Issues::UpdateService` and not in `IssuableBaseService#filtr_params` + # filter confidential in `Issues::UpdateService` and not in `IssuableBaseService#filter_params` # because we do allow users that cannot admin issues to set confidential flag when creating an issue unless can_admin_issuable?(issue) params.delete(:confidential) @@ -42,10 +42,6 @@ module Issues ).execute(spam_params: spam_params) end - def after_update(issue) - IssuesChannel.broadcast_to(issue, event: 'updated') if Gitlab::ActionCable::Config.in_app? || Feature.enabled?(:broadcast_issue_updates, issue.project) - end - def handle_changes(issue, options) old_associations = options.fetch(:old_associations, {}) old_labels = old_associations.fetch(:labels, []) @@ -61,12 +57,7 @@ module Issues todo_service.update_issue(issue, current_user, old_mentioned_users) end - if issue.assignees != old_assignees - create_assignee_note(issue, old_assignees) - notification_service.async.reassigned_issue(issue, current_user, old_assignees) - todo_service.reassigned_assignable(issue, current_user, old_assignees) - track_incident_action(current_user, issue, :incident_assigned) - end + handle_assignee_changes(issue, old_assignees) if issue.previous_changes.include?('confidential') # don't enqueue immediately to prevent todos removal in case of a mistake @@ -90,12 +81,27 @@ module Issues end end + def handle_assignee_changes(issue, old_assignees) + return if issue.assignees == old_assignees + + create_assignee_note(issue, old_assignees) + notification_service.async.reassigned_issue(issue, current_user, old_assignees) + todo_service.reassigned_assignable(issue, current_user, old_assignees) + track_incident_action(current_user, issue, :incident_assigned) + + if Gitlab::ActionCable::Config.in_app? || Feature.enabled?(:broadcast_issue_updates, issue.project) + GraphqlTriggers.issuable_assignees_updated(issue) + end + end + def handle_task_changes(issuable) todo_service.resolve_todos_for_target(issuable, current_user) todo_service.update_issue(issuable, current_user) end def handle_move_between_ids(issue) + issue.check_repositioning_allowed! if params[:move_between_ids] + super rebalance_if_needed(issue) @@ -113,7 +119,7 @@ module Issues canonical_issue = IssuesFinder.new(current_user).find_by(id: canonical_issue_id) if canonical_issue - Issues::DuplicateService.new(project, current_user).execute(issue, canonical_issue) + Issues::DuplicateService.new(project: project, current_user: current_user).execute(issue, canonical_issue) end end # rubocop: enable CodeReuse/ActiveRecord @@ -126,7 +132,7 @@ module Issues target_project != issue.project update(issue) - Issues::MoveService.new(project, current_user).execute(issue, target_project) + Issues::MoveService.new(project: project, current_user: current_user).execute(issue, target_project) end private @@ -142,14 +148,14 @@ module Issues # we've pre-empted this from running in #execute, so let's go ahead and update the Issue now. update(issue) - Issues::CloneService.new(project, current_user).execute(issue, target_project, with_notes: with_notes) + Issues::CloneService.new(project: project, current_user: current_user).execute(issue, target_project, with_notes: with_notes) end def create_merge_request_from_quick_action create_merge_request_params = params.delete(:create_merge_request) return unless create_merge_request_params - MergeRequests::CreateFromIssueService.new(project, current_user, create_merge_request_params).execute + MergeRequests::CreateFromIssueService.new(project: project, current_user: current_user, mr_params: create_merge_request_params).execute end def handle_milestone_change(issue) @@ -201,4 +207,4 @@ module Issues end end -Issues::UpdateService.prepend_if_ee('EE::Issues::UpdateService') +Issues::UpdateService.prepend_mod_with('Issues::UpdateService') diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb index 1384e2f83b2..ef48134dec4 100644 --- a/app/services/issues/zoom_link_service.rb +++ b/app/services/issues/zoom_link_service.rb @@ -2,10 +2,10 @@ module Issues class ZoomLinkService < Issues::BaseService - def initialize(issue, user) - super(issue.project, user) + def initialize(project:, current_user:, params:) + super - @issue = issue + @issue = params.fetch(:issue) @added_meeting = ZoomMeeting.canonical_meeting(@issue) end diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb index 88cfe684125..c9ffdeb2a16 100644 --- a/app/services/jira_import/start_import_service.rb +++ b/app/services/jira_import/start_import_service.rb @@ -41,7 +41,7 @@ module JiraImport project.save! && jira_import.schedule! ServiceResponse.success(payload: { import_data: jira_import } ) - rescue => ex + rescue StandardError => ex # in case project.save! raises an error Gitlab::ErrorTracking.track_exception(ex, project_id: project.id) jira_import&.do_fail!(error_message: ex.message) diff --git a/app/services/keys/create_service.rb b/app/services/keys/create_service.rb index c1c3ef8792f..507537391ed 100644 --- a/app/services/keys/create_service.rb +++ b/app/services/keys/create_service.rb @@ -19,4 +19,4 @@ module Keys end end -Keys::CreateService.prepend_if_ee('EE::Keys::CreateService') +Keys::CreateService.prepend_mod_with('Keys::CreateService') diff --git a/app/services/keys/destroy_service.rb b/app/services/keys/destroy_service.rb index 4552c5cf9a2..eaf5eb35f58 100644 --- a/app/services/keys/destroy_service.rb +++ b/app/services/keys/destroy_service.rb @@ -13,4 +13,4 @@ module Keys end end -Keys::DestroyService.prepend_if_ee('EE::Keys::DestroyService') +Keys::DestroyService.prepend_mod_with('Keys::DestroyService') diff --git a/app/services/labels/available_labels_service.rb b/app/services/labels/available_labels_service.rb index 1d022740c44..ff29358df86 100644 --- a/app/services/labels/available_labels_service.rb +++ b/app/services/labels/available_labels_service.rb @@ -14,15 +14,16 @@ module Labels return [] unless labels - labels = labels.split(',') if labels.is_a?(String) + labels = labels.split(',').map(&:strip) if labels.is_a?(String) + existing_labels = LabelsFinder.new(current_user, finder_params(labels)).execute.index_by(&:title) labels.map do |label_name| label = Labels::FindOrCreateService.new( current_user, parent, include_ancestor_groups: true, - title: label_name.strip, - available_labels: available_labels + title: label_name, + existing_labels_by_title: existing_labels ).execute(find_only: find_only) label @@ -45,18 +46,19 @@ module Labels private - def finder_params - params = { include_ancestor_groups: true } + def finder_params(titles = nil) + finder_params = { include_ancestor_groups: true } + finder_params[:title] = titles if titles case parent when Group - params[:group_id] = parent.id - params[:only_group_labels] = true + finder_params[:group_id] = parent.id + finder_params[:only_group_labels] = true when Project - params[:project_id] = parent.id + finder_params[:project_id] = parent.id end - params + finder_params end end end diff --git a/app/services/labels/create_service.rb b/app/services/labels/create_service.rb index 665d1035b2b..6c070d15cdb 100644 --- a/app/services/labels/create_service.rb +++ b/app/services/labels/create_service.rb @@ -26,4 +26,4 @@ module Labels end end -Labels::CreateService.prepend_if_ee('EE::Labels::CreateService') +Labels::CreateService.prepend_mod_with('Labels::CreateService') diff --git a/app/services/labels/find_or_create_service.rb b/app/services/labels/find_or_create_service.rb index a47dd42aea0..112d156c214 100644 --- a/app/services/labels/find_or_create_service.rb +++ b/app/services/labels/find_or_create_service.rb @@ -6,6 +6,7 @@ module Labels @current_user = current_user @parent = parent @available_labels = params.delete(:available_labels) + @existing_labels_by_title = params.delete(:existing_labels_by_title) @params = params.dup.with_indifferent_access end @@ -16,7 +17,7 @@ module Labels private - attr_reader :current_user, :parent, :params, :skip_authorization + attr_reader :current_user, :parent, :params, :skip_authorization, :existing_labels_by_title def available_labels @available_labels ||= LabelsFinder.new( @@ -29,9 +30,8 @@ module Labels # Only creates the label if current_user can do so, if the label does not exist # and the user can not create the label, nil is returned - # rubocop: disable CodeReuse/ActiveRecord def find_or_create_label(find_only: false) - new_label = available_labels.find_by(title: title) + new_label = find_existing_label(title) return new_label if find_only @@ -42,6 +42,13 @@ module Labels new_label end + + # rubocop: disable CodeReuse/ActiveRecord + def find_existing_label(title) + return existing_labels_by_title[title] if existing_labels_by_title + + available_labels.find_by(title: title) + end # rubocop: enable CodeReuse/ActiveRecord def title diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb index fdf2cf13f92..e3b110f8f26 100644 --- a/app/services/labels/promote_service.rb +++ b/app/services/labels/promote_service.rb @@ -101,4 +101,4 @@ module Labels end end -Labels::PromoteService.prepend_if_ee('EE::Labels::PromoteService') +Labels::PromoteService.prepend_mod_with('Labels::PromoteService') diff --git a/app/services/lfs/lock_file_service.rb b/app/services/lfs/lock_file_service.rb index 1b283018c16..54f193c86e6 100644 --- a/app/services/lfs/lock_file_service.rb +++ b/app/services/lfs/lock_file_service.rb @@ -12,7 +12,7 @@ module Lfs error('already locked', 409, current_lock) rescue Gitlab::GitAccess::ForbiddenError => ex error(ex.message, 403) - rescue => ex + rescue StandardError => ex error(ex.message, 500) end @@ -42,4 +42,4 @@ module Lfs end end -Lfs::LockFileService.prepend_if_ee('EE::Lfs::LockFileService') +Lfs::LockFileService.prepend_mod_with('Lfs::LockFileService') diff --git a/app/services/lfs/locks_finder_service.rb b/app/services/lfs/locks_finder_service.rb index 192ce3d3c2a..a77be643478 100644 --- a/app/services/lfs/locks_finder_service.rb +++ b/app/services/lfs/locks_finder_service.rb @@ -4,7 +4,7 @@ module Lfs class LocksFinderService < BaseService def execute success(locks: find_locks) - rescue => ex + rescue StandardError => ex error(ex.message, 500) end diff --git a/app/services/lfs/push_service.rb b/app/services/lfs/push_service.rb index 9b947fbed07..e21988aa561 100644 --- a/app/services/lfs/push_service.rb +++ b/app/services/lfs/push_service.rb @@ -16,12 +16,17 @@ module Lfs end success - rescue => err + rescue StandardError => err + Gitlab::ErrorTracking.log_exception(err, extra_context) error(err.message) end private + def extra_context + { project_id: project.id, user_id: current_user&.id }.compact + end + # Currently we only set repository_type for design repository objects, so # push mirroring must send objects with a `nil` repository type - but if the # wiki repository uses LFS, its objects will also be sent. This will be diff --git a/app/services/lfs/unlock_file_service.rb b/app/services/lfs/unlock_file_service.rb index a13e89904a0..7a3025ee7ea 100644 --- a/app/services/lfs/unlock_file_service.rb +++ b/app/services/lfs/unlock_file_service.rb @@ -12,7 +12,7 @@ module Lfs error(ex.message, 403) rescue ActiveRecord::RecordNotFound error(_('Lock not found'), 404) - rescue => ex + rescue StandardError => ex error(ex.message, 500) end @@ -46,4 +46,4 @@ module Lfs end end -Lfs::UnlockFileService.prepend_if_ee('EE::Lfs::UnlockFileService') +Lfs::UnlockFileService.prepend_mod_with('Lfs::UnlockFileService') diff --git a/app/services/members/approve_access_request_service.rb b/app/services/members/approve_access_request_service.rb index e79c5f69a30..919c22894c1 100644 --- a/app/services/members/approve_access_request_service.rb +++ b/app/services/members/approve_access_request_service.rb @@ -21,4 +21,4 @@ module Members end end -Members::ApproveAccessRequestService.prepend_if_ee('EE::Members::ApproveAccessRequestService') +Members::ApproveAccessRequestService.prepend_mod_with('Members::ApproveAccessRequestService') diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 953cf7f5bf6..7b81cc27635 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -98,4 +98,4 @@ module Members end end -Members::CreateService.prepend_if_ee('EE::Members::CreateService') +Members::CreateService.prepend_mod_with('Members::CreateService') diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb index 8cad065e6cc..bb2d419c046 100644 --- a/app/services/members/destroy_service.rb +++ b/app/services/members/destroy_service.rb @@ -117,4 +117,4 @@ module Members end end -Members::DestroyService.prepend_if_ee('EE::Members::DestroyService') +Members::DestroyService.prepend_mod_with('Members::DestroyService') diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index 5c6e51201c2..257698f65ae 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -31,4 +31,4 @@ module Members end end -Members::UpdateService.prepend_if_ee('EE::Members::UpdateService') +Members::UpdateService.prepend_mod_with('Members::UpdateService') diff --git a/app/services/merge_request_metrics_service.rb b/app/services/merge_request_metrics_service.rb index 9ea71838011..d86bcca8892 100644 --- a/app/services/merge_request_metrics_service.rb +++ b/app/services/merge_request_metrics_service.rb @@ -20,4 +20,4 @@ class MergeRequestMetricsService end end -MergeRequestMetricsService.prepend_if_ee('EE::MergeRequestMetricsService') +MergeRequestMetricsService.prepend_mod_with('MergeRequestMetricsService') diff --git a/app/services/merge_requests/add_context_service.rb b/app/services/merge_requests/add_context_service.rb index 77b00f645c9..7b441ddf5e4 100644 --- a/app/services/merge_requests/add_context_service.rb +++ b/app/services/merge_requests/add_context_service.rb @@ -50,7 +50,7 @@ module MergeRequests def duplicates existing_oids = merge_request.merge_request_context_commits.map { |commit| commit.sha.to_s } existing_oids.select do |existing_oid| - commit_ids.select { |commit_id| existing_oid.start_with?(commit_id) }.count > 0 + commit_ids.count { |commit_id| existing_oid.start_with?(commit_id) } > 0 end end diff --git a/app/services/merge_requests/add_spent_time_service.rb b/app/services/merge_requests/add_spent_time_service.rb new file mode 100644 index 00000000000..ae79645a96a --- /dev/null +++ b/app/services/merge_requests/add_spent_time_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MergeRequests + class AddSpentTimeService < UpdateService + def execute(merge_request) + old_associations = { total_time_spent: merge_request.total_time_spent } + + merge_request.spend_time(params[:spend_time]) + + merge_request_saved = merge_request.with_transaction_returning_status do + merge_request.save + end + + if merge_request_saved + create_system_notes(merge_request) + + # track usage + track_time_spend_edits(merge_request, old_associations[:total_time_spent]) + + execute_hooks(merge_request, 'update', old_associations: old_associations) + end + + merge_request + end + + private + + def track_time_spend_edits(merge_request, old_total_time_spent) + if old_total_time_spent != merge_request.total_time_spent + merge_request_activity_counter.track_time_spent_changed_action(user: current_user) + end + end + end +end diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb index ed9747a8c99..77564521d45 100644 --- a/app/services/merge_requests/after_create_service.rb +++ b/app/services/merge_requests/after_create_service.rb @@ -35,9 +35,9 @@ module MergeRequests end def link_lfs_objects(merge_request) - LinkLfsObjectsService.new(merge_request.target_project).execute(merge_request) + LinkLfsObjectsService.new(project: merge_request.target_project).execute(merge_request) end end end -MergeRequests::AfterCreateService.prepend_if_ee('EE::MergeRequests::AfterCreateService') +MergeRequests::AfterCreateService.prepend_mod_with('MergeRequests::AfterCreateService') diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index 59d8f553eff..62e599e3e27 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -54,4 +54,4 @@ module MergeRequests end end -MergeRequests::ApprovalService.prepend_if_ee('EE::MergeRequests::ApprovalService') +MergeRequests::ApprovalService.prepend_mod_with('MergeRequests::ApprovalService') diff --git a/app/services/merge_requests/assign_issues_service.rb b/app/services/merge_requests/assign_issues_service.rb index e9107b9998e..f016c16e816 100644 --- a/app/services/merge_requests/assign_issues_service.rb +++ b/app/services/merge_requests/assign_issues_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module MergeRequests - class AssignIssuesService < BaseService + class AssignIssuesService < BaseProjectService def assignable_issues @assignable_issues ||= begin if current_user == merge_request.author @@ -16,7 +16,7 @@ module MergeRequests def execute assignable_issues.each do |issue| - Issues::UpdateService.new(issue.project, current_user, assignee_ids: [current_user.id]).execute(issue) + Issues::UpdateService.new(project: issue.project, current_user: current_user, params: { assignee_ids: [current_user.id] }).execute(issue) end { diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 3a3765355d8..e94274aff9d 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -147,7 +147,7 @@ module MergeRequests if async MergeRequests::CreatePipelineWorker.perform_async(project.id, user.id, merge_request.id) else - MergeRequests::CreatePipelineService.new(project, user).execute(merge_request) + MergeRequests::CreatePipelineService.new(project: project, current_user: user).execute(merge_request) end end @@ -208,4 +208,4 @@ module MergeRequests end end -MergeRequests::BaseService.prepend_if_ee('EE::MergeRequests::BaseService') +MergeRequests::BaseService.prepend_mod_with('MergeRequests::BaseService') diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index ecc55eae5de..878e42172b7 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -300,4 +300,4 @@ module MergeRequests end end -MergeRequests::BuildService.prepend_if_ee('EE::MergeRequests::BuildService') +MergeRequests::BuildService.prepend_mod_with('MergeRequests::BuildService') diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb index b43e697d3ab..12fc828b194 100644 --- a/app/services/merge_requests/create_from_issue_service.rb +++ b/app/services/merge_requests/create_from_issue_service.rb @@ -2,16 +2,28 @@ module MergeRequests class CreateFromIssueService < MergeRequests::CreateService - def initialize(project, user, params) + # TODO: This constructor does not use the "params:" argument from the superclass, + # but instead has a custom "mr_params:" argument. This is because historically, + # prior to named arguments being introduced to the constructor, it never passed + # along the third positional argument when calling `super`. + # This should be changed, in order to be consistent (all subclasses should pass + # along all of the arguments to the superclass, otherwise it is probably not an + # "is a" relationship). However, we need to be sure that passing the params + # argument to `super` (especially target_project_id) will not cause any unexpected + # behavior in the superclass. Since the addition of the named arguments is + # intended to be a low-risk pure refactor, we will defer this fix + # to this follow-on issue: + # https://gitlab.com/gitlab-org/gitlab/-/issues/328726 + def initialize(project:, current_user:, mr_params: {}) # branch - the name of new branch # ref - the source of new branch. - @branch_name = params[:branch_name] - @issue_iid = params[:issue_iid] - @ref = params[:ref] - @target_project_id = params[:target_project_id] + @branch_name = mr_params[:branch_name] + @issue_iid = mr_params[:issue_iid] + @ref = mr_params[:ref] + @target_project_id = mr_params[:target_project_id] - super(project, user) + super(project: project, current_user: current_user) end def execute @@ -73,11 +85,11 @@ module MergeRequests end def default_branch - target_project.default_branch || 'master' + target_project.default_branch_or_main end def merge_request - MergeRequests::BuildService.new(target_project, current_user, merge_request_params).execute + MergeRequests::BuildService.new(project: target_project, current_user: current_user, params: merge_request_params).execute end def merge_request_params diff --git a/app/services/merge_requests/create_pipeline_service.rb b/app/services/merge_requests/create_pipeline_service.rb index 46c4c102091..ebeba0ee5b8 100644 --- a/app/services/merge_requests/create_pipeline_service.rb +++ b/app/services/merge_requests/create_pipeline_service.rb @@ -63,4 +63,4 @@ module MergeRequests end end -MergeRequests::CreatePipelineService.prepend_if_ee('EE::MergeRequests::CreatePipelineService') +MergeRequests::CreatePipelineService.prepend_mod_with('MergeRequests::CreatePipelineService') diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 8186472ec65..c1292d924b2 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -53,4 +53,4 @@ module MergeRequests end end -MergeRequests::CreateService.include_if_ee('EE::MergeRequests::CreateService') +MergeRequests::CreateService.include_mod_with('MergeRequests::CreateService') diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb index de3f2acdf63..7996fcb5273 100644 --- a/app/services/merge_requests/get_urls_service.rb +++ b/app/services/merge_requests/get_urls_service.rb @@ -1,13 +1,7 @@ # frozen_string_literal: true module MergeRequests - class GetUrlsService < BaseService - attr_reader :project - - def initialize(project) - @project = project - end - + class GetUrlsService < BaseProjectService def execute(changes) return [] unless project&.printing_merge_request_link_enabled diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb index 77ff0791eb4..9ac386110f7 100644 --- a/app/services/merge_requests/handle_assignees_change_service.rb +++ b/app/services/merge_requests/handle_assignees_change_service.rb @@ -3,17 +3,13 @@ module MergeRequests class HandleAssigneesChangeService < MergeRequests::BaseService def async_execute(merge_request, old_assignees, options = {}) - if Feature.enabled?(:async_handle_merge_request_assignees_change, merge_request.target_project, default_enabled: :yaml) - MergeRequests::HandleAssigneesChangeWorker - .perform_async( - merge_request.id, - current_user.id, - old_assignees.map(&:id), - options - ) - else - execute(merge_request, old_assignees, options) - end + MergeRequests::HandleAssigneesChangeWorker + .perform_async( + merge_request.id, + current_user.id, + old_assignees.map(&:id), + options + ) end def execute(merge_request, old_assignees, options = {}) @@ -40,4 +36,4 @@ module MergeRequests end end -MergeRequests::HandleAssigneesChangeService.prepend_if_ee('EE::MergeRequests::HandleAssigneesChangeService') +MergeRequests::HandleAssigneesChangeService.prepend_mod_with('MergeRequests::HandleAssigneesChangeService') diff --git a/app/services/merge_requests/link_lfs_objects_service.rb b/app/services/merge_requests/link_lfs_objects_service.rb index 191da594095..4981d3efcae 100644 --- a/app/services/merge_requests/link_lfs_objects_service.rb +++ b/app/services/merge_requests/link_lfs_objects_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module MergeRequests - class LinkLfsObjectsService < ::BaseService + class LinkLfsObjectsService < ::BaseProjectService def execute(merge_request, oldrev: merge_request.diff_base_sha, newrev: merge_request.diff_head_sha) return if merge_request.source_project == project return if no_changes?(oldrev, newrev) diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb index fe09c92aab9..3b9d3bccacf 100644 --- a/app/services/merge_requests/merge_base_service.rb +++ b/app/services/merge_requests/merge_base_service.rb @@ -61,7 +61,7 @@ module MergeRequests def squash_sha! params[:merge_request] = merge_request - squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute + squash_result = ::MergeRequests::SquashService.new(project: project, current_user: current_user, params: params).execute case squash_result[:status] when :success @@ -73,4 +73,4 @@ module MergeRequests end end -MergeRequests::MergeBaseService.prepend_if_ee('EE::MergeRequests::MergeBaseService') +MergeRequests::MergeBaseService.prepend_mod_with('MergeRequests::MergeBaseService') diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 27f474b0fe7..5e7eee4f1c3 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -8,16 +8,22 @@ module MergeRequests # Executed when you do merge via GitLab UI # class MergeService < MergeRequests::MergeBaseService + include Gitlab::Utils::StrongMemoize + GENERIC_ERROR_MESSAGE = 'An error occurred while merging' + LEASE_TIMEOUT = 15.minutes.to_i delegate :merge_jid, :state, to: :@merge_request def execute(merge_request, options = {}) if project.merge_requests_ff_only_enabled && !self.is_a?(FfMergeService) - FfMergeService.new(project, current_user, params).execute(merge_request) + FfMergeService.new(project: project, current_user: current_user, params: params).execute(merge_request) return end + return if merge_request.merged? + return unless exclusive_lease(merge_request.id).try_obtain + @merge_request = merge_request @options = options @@ -34,6 +40,8 @@ module MergeRequests log_info("Merge process finished on JID #{merge_jid} with state #{state}") rescue MergeError => e handle_merge_error(log_message: e.message, save_message_on_model: true) + ensure + exclusive_lease(merge_request.id).cancel end private @@ -96,14 +104,14 @@ module MergeRequests rescue Gitlab::Git::PreReceiveError => e raise MergeError, "Something went wrong during merge pre-receive hook. #{e.message}".strip - rescue => e + rescue StandardError => e handle_merge_error(log_message: e.message) raise_error(GENERIC_ERROR_MESSAGE) end def after_merge log_info("Post merge started on JID #{merge_jid} with state #{state}") - MergeRequests::PostMergeService.new(project, current_user).execute(merge_request) + MergeRequests::PostMergeService.new(project: project, current_user: current_user).execute(merge_request) log_info("Post merge finished on JID #{merge_jid} with state #{state}") if delete_source_branch? @@ -146,5 +154,13 @@ module MergeRequests # loaded from the database they're strings params.with_indifferent_access[:sha] == merge_request.diff_head_sha end + + def exclusive_lease(merge_request_id) + strong_memoize(:"exclusive_lease_#{merge_request_id}") do + lease_key = ['merge_requests_merge_service', merge_request_id].join(':') + + Gitlab::ExclusiveLease.new(lease_key, timeout: LEASE_TIMEOUT) + end + end end end diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb index 9fecab85cc1..3e294aeaa07 100644 --- a/app/services/merge_requests/mergeability_check_service.rb +++ b/app/services/merge_requests/mergeability_check_service.rb @@ -157,7 +157,7 @@ module MergeRequests def merge_to_ref params = { allow_conflicts: Feature.enabled?(:display_merge_conflicts_in_diff, project) } - result = MergeRequests::MergeToRefService.new(project, merge_request.author, params).execute(merge_request) + result = MergeRequests::MergeToRefService.new(project: project, current_user: merge_request.author, params: params).execute(merge_request) result[:status] == :success end diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index 4d7d632ee14..ea3071b3c2d 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -12,20 +12,28 @@ module MergeRequests MAX_RETARGET_MERGE_REQUESTS = 4 def execute(merge_request) + return if merge_request.merged? + + # Mark the merge request as merged, everything that happens afterwards is + # executed once merge_request.mark_as_merged - close_issues(merge_request) - todo_service.merge_merge_request(merge_request, current_user) + create_event(merge_request) - create_note(merge_request) + todo_service.merge_merge_request(merge_request, current_user) + merge_request_activity_counter.track_merge_mr_action(user: current_user) + + create_note(merge_request) + close_issues(merge_request) notification_service.merge_mr(merge_request, current_user) - execute_hooks(merge_request, 'merge') invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers) merge_request.update_project_counter_caches delete_non_latest_diffs(merge_request) cancel_review_app_jobs!(merge_request) cleanup_environments(merge_request) cleanup_refs(merge_request) + + execute_hooks(merge_request, 'merge') end private @@ -36,7 +44,7 @@ module MergeRequests closed_issues = merge_request.visible_closing_issues_for(current_user) closed_issues.each do |issue| - Issues::CloseService.new(project, current_user).execute(issue, commit: merge_request) + Issues::CloseService.new(project: project, current_user: current_user).execute(issue, commit: merge_request) end end @@ -59,4 +67,4 @@ module MergeRequests end end -MergeRequests::PostMergeService.prepend_if_ee('EE::MergeRequests::PostMergeService') +MergeRequests::PostMergeService.prepend_mod_with('MergeRequests::PostMergeService') diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb index 05ec87c7d60..cc1e08e1606 100644 --- a/app/services/merge_requests/push_options_handler_service.rb +++ b/app/services/merge_requests/push_options_handler_service.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true module MergeRequests - class PushOptionsHandlerService + class PushOptionsHandlerService < ::BaseProjectService LIMIT = 10 - attr_reader :current_user, :errors, :changes, - :project, :push_options, :target_project + attr_reader :errors, :changes, + :push_options, :target_project + + def initialize(project:, current_user:, params: {}, changes:, push_options:) + super(project: project, current_user: current_user, params: params) - def initialize(project, current_user, changes, push_options) - @project = project @target_project = @project.default_merge_request_target - @current_user = current_user @changes = Gitlab::ChangesList.new(changes) @push_options = push_options @errors = [] @@ -95,16 +95,16 @@ module MergeRequests # Use BuildService to assign the standard attributes of a merge request merge_request = ::MergeRequests::BuildService.new( - project, - current_user, - create_params(branch) + project: project, + current_user: current_user, + params: create_params(branch) ).execute unless merge_request.errors.present? merge_request = ::MergeRequests::CreateService.new( - project, - current_user, - merge_request.attributes.merge(assignees: merge_request.assignees, + project: project, + current_user: current_user, + params: merge_request.attributes.merge(assignees: merge_request.assignees, label_ids: merge_request.label_ids) ).execute end @@ -114,9 +114,9 @@ module MergeRequests def update!(merge_request) merge_request = ::MergeRequests::UpdateService.new( - target_project, - current_user, - update_params(merge_request) + project: target_project, + current_user: current_user, + params: update_params(merge_request) ).execute(merge_request) collect_errors_from_merge_request(merge_request) unless merge_request.valid? diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index 87808a21a15..ae8398e2335 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -27,7 +27,7 @@ module MergeRequests repository.rebase(current_user, merge_request, skip_ci: @skip_ci) true - rescue => e + rescue StandardError => e log_error(exception: e, message: REBASE_ERROR, save_message_on_model: true) false diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index e04c5168cef..d5e2595a9c6 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -62,7 +62,7 @@ module MergeRequests # the latest diff state as the last _valid_ one. merge_requests_for_source_branch.reject(&:source_branch_exists?).each do |mr| MergeRequests::CloseService - .new(mr.target_project, @current_user) + .new(project: mr.target_project, current_user: @current_user) .execute(mr) end end @@ -96,7 +96,7 @@ module MergeRequests merge_request.merge_commit_sha = analyzer.get_merge_commit(merge_request.diff_head_sha) MergeRequests::PostMergeService - .new(merge_request.target_project, @current_user) + .new(project: merge_request.target_project, current_user: @current_user) .execute(merge_request) end end @@ -109,7 +109,7 @@ module MergeRequests merge_requests_for_forks.find_each do |mr| LinkLfsObjectsService - .new(mr.target_project) + .new(project: mr.target_project) .execute(mr, oldrev: @push.oldrev, newrev: @push.newrev) end end @@ -162,12 +162,7 @@ module MergeRequests end def refresh_pipelines_on_merge_requests(merge_request) - if Feature.enabled?(:code_review_async_pipeline_creation, project, default_enabled: :yaml) - create_pipeline_for(merge_request, current_user, async: true) - else - create_pipeline_for(merge_request, current_user, async: false) - UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) - end + create_pipeline_for(merge_request, current_user, async: true) end def abort_auto_merges(merge_request) @@ -218,7 +213,7 @@ module MergeRequests # If the a commit no longer exists in this repo, gitlab_git throws # a Rugged::OdbError. This is fixed in https://gitlab.com/gitlab-org/gitlab_git/merge_requests/52 @commits = @project.repository.commits_between(common_ref, @push.newrev) if common_ref - rescue + rescue StandardError end elsif @push.branch_removed? # No commits for a deleted branch. @@ -309,4 +304,4 @@ module MergeRequests end end -MergeRequests::RefreshService.prepend_if_ee('EE::MergeRequests::RefreshService') +MergeRequests::RefreshService.prepend_mod_with('MergeRequests::RefreshService') diff --git a/app/services/merge_requests/remove_approval_service.rb b/app/services/merge_requests/remove_approval_service.rb index f2bf5de61c1..872e7e0c89c 100644 --- a/app/services/merge_requests/remove_approval_service.rb +++ b/app/services/merge_requests/remove_approval_service.rb @@ -41,4 +41,4 @@ module MergeRequests end end -MergeRequests::RemoveApprovalService.prepend_if_ee('EE::MergeRequests::RemoveApprovalService') +MergeRequests::RemoveApprovalService.prepend_mod_with('MergeRequests::RemoveApprovalService') diff --git a/app/services/merge_requests/resolve_todos_service.rb b/app/services/merge_requests/resolve_todos_service.rb index 0010b596eee..2d322a7de30 100644 --- a/app/services/merge_requests/resolve_todos_service.rb +++ b/app/services/merge_requests/resolve_todos_service.rb @@ -10,11 +10,7 @@ module MergeRequests end def async_execute - if Feature.enabled?(:resolve_merge_request_todos_async, merge_request.target_project, default_enabled: :yaml) - MergeRequests::ResolveTodosWorker.perform_async(merge_request.id, user.id) - else - execute - end + MergeRequests::ResolveTodosWorker.perform_async(merge_request.id, user.id) end def execute diff --git a/app/services/merge_requests/retarget_chain_service.rb b/app/services/merge_requests/retarget_chain_service.rb index e8101e447d2..dab6e198979 100644 --- a/app/services/merge_requests/retarget_chain_service.rb +++ b/app/services/merge_requests/retarget_chain_service.rb @@ -24,9 +24,11 @@ module MergeRequests next unless can?(current_user, :update_merge_request, other_merge_request.source_project) ::MergeRequests::UpdateService - .new(other_merge_request.source_project, current_user, - target_branch: merge_request.target_branch, - target_branch_was_deleted: true) + .new(project: other_merge_request.source_project, current_user: current_user, + params: { + target_branch: merge_request.target_branch, + target_branch_was_deleted: true + }) .execute(other_merge_request) end end diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb index faa2e921581..31c49b3ae70 100644 --- a/app/services/merge_requests/squash_service.rb +++ b/app/services/merge_requests/squash_service.rb @@ -29,7 +29,7 @@ module MergeRequests squash_sha = repository.squash(current_user, merge_request, message || merge_request.default_squash_commit_message) success(squash_sha: squash_sha) - rescue => e + rescue StandardError => e log_error(exception: e, message: 'Failed to squash merge request') false @@ -37,7 +37,7 @@ module MergeRequests def squash_in_progress? merge_request.squash_in_progress? - rescue => e + rescue StandardError => e log_error(exception: e, message: 'Failed to check squash in progress') raise SquashInProgressError, e.message diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb index b339a644e8c..f99db35fd49 100644 --- a/app/services/merge_requests/update_assignees_service.rb +++ b/app/services/merge_requests/update_assignees_service.rb @@ -20,7 +20,7 @@ module MergeRequests # Defer the more expensive operations (handle_assignee_changes) to the background MergeRequests::HandleAssigneesChangeService - .new(project, current_user) + .new(project: project, current_user: current_user) .async_execute(merge_request, old_assignees, execute_hooks: true) merge_request @@ -45,7 +45,7 @@ module MergeRequests end def assignee_ids - params.fetch(:assignee_ids).first(1) + params.fetch(:assignee_ids).reject { _1 == 0 }.first(1) end def params @@ -61,4 +61,4 @@ module MergeRequests end end -MergeRequests::UpdateAssigneesService.prepend_if_ee('EE::MergeRequests::UpdateAssigneesService') +MergeRequests::UpdateAssigneesService.prepend_mod_with('MergeRequests::UpdateAssigneesService') diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 8995c5f2411..b613d88aee4 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -4,7 +4,7 @@ module MergeRequests class UpdateService < MergeRequests::BaseService extend ::Gitlab::Utils::Override - def initialize(project, user = nil, params = {}) + def initialize(project:, current_user: nil, params: {}) super @target_branch_was_deleted = @params.delete(:target_branch_was_deleted) @@ -222,7 +222,7 @@ module MergeRequests def handle_assignees_change(merge_request, old_assignees) MergeRequests::HandleAssigneesChangeService - .new(project, current_user) + .new(project: project, current_user: current_user) .async_execute(merge_request, old_assignees) end @@ -295,6 +295,8 @@ module MergeRequests case attribute when :assignee_ids assignees_service.execute(merge_request) + when :spend_time + add_time_spent_service.execute(merge_request) else nil end @@ -302,9 +304,13 @@ module MergeRequests def assignees_service @assignees_service ||= ::MergeRequests::UpdateAssigneesService - .new(project, current_user, params) + .new(project: project, current_user: current_user, params: params) + end + + def add_time_spent_service + @add_time_spent_service ||= ::MergeRequests::AddSpentTimeService.new(project: project, current_user: current_user, params: params) end end end -MergeRequests::UpdateService.prepend_if_ee('EE::MergeRequests::UpdateService') +MergeRequests::UpdateService.prepend_mod_with('MergeRequests::UpdateService') diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb index 6069d236e82..e94c8d92c3a 100644 --- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb +++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb @@ -80,7 +80,7 @@ module Metrics def fetch_dashboard uid = GrafanaUidParser.new(grafana_url, project).parse - raise DashboardProcessingError.new(_('Dashboard uid not found')) unless uid + raise DashboardProcessingError, _('Dashboard uid not found') unless uid response = client.get_dashboard(uid: uid) @@ -89,7 +89,7 @@ module Metrics def fetch_datasource(dashboard) name = DatasourceNameParser.new(grafana_url, dashboard).parse - raise DashboardProcessingError.new(_('Datasource name not found')) unless name + raise DashboardProcessingError, _('Datasource name not found') unless name response = client.get_datasource(name: name) @@ -115,7 +115,7 @@ module Metrics def parse_json(json) Gitlab::Json.parse(json, symbolize_names: true) rescue JSON::ParserError - raise DashboardProcessingError.new(_('Grafana response contains invalid json')) + raise DashboardProcessingError, _('Grafana response contains invalid json') end end diff --git a/app/services/metrics/dashboard/transient_embed_service.rb b/app/services/metrics/dashboard/transient_embed_service.rb index 0a9c4bc7b86..29ea9909a36 100644 --- a/app/services/metrics/dashboard/transient_embed_service.rb +++ b/app/services/metrics/dashboard/transient_embed_service.rb @@ -39,7 +39,7 @@ module Metrics end def invalid_embed_json!(message) - raise DashboardProcessingError.new(_("Parsing error for param :embed_json. %{message}") % { message: message }) + raise DashboardProcessingError, _("Parsing error for param :embed_json. %{message}") % { message: message } end end end diff --git a/app/services/metrics/dashboard/update_dashboard_service.rb b/app/services/metrics/dashboard/update_dashboard_service.rb index d990e96ecb5..0574cb15e96 100644 --- a/app/services/metrics/dashboard/update_dashboard_service.rb +++ b/app/services/metrics/dashboard/update_dashboard_service.rb @@ -58,7 +58,7 @@ module Metrics target_branch: project.default_branch, title: params[:commit_message] } - merge_request = ::MergeRequests::CreateService.new(project, current_user, merge_request_params).execute + merge_request = ::MergeRequests::CreateService.new(project: project, current_user: current_user, params: merge_request_params).execute if merge_request.persisted? success(result.merge(merge_request: Gitlab::UrlBuilder.build(merge_request))) diff --git a/app/services/milestones/destroy_service.rb b/app/services/milestones/destroy_service.rb index 87c7a282081..2563f2f5390 100644 --- a/app/services/milestones/destroy_service.rb +++ b/app/services/milestones/destroy_service.rb @@ -7,11 +7,11 @@ module Milestones update_params = { milestone: nil, skip_milestone_email: true } milestone.issues.each do |issue| - Issues::UpdateService.new(parent, current_user, update_params).execute(issue) + Issues::UpdateService.new(project: parent, current_user: current_user, params: update_params).execute(issue) end milestone.merge_requests.each do |merge_request| - MergeRequests::UpdateService.new(parent, current_user, update_params).execute(merge_request) + MergeRequests::UpdateService.new(project: parent, current_user: current_user, params: update_params).execute(merge_request) end log_destroy_event_for(milestone) diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb index 2431318cbb2..4417f17f33e 100644 --- a/app/services/milestones/promote_service.rb +++ b/app/services/milestones/promote_service.rb @@ -90,4 +90,4 @@ module Milestones end end -Milestones::PromoteService.prepend_if_ee('EE::Milestones::PromoteService') +Milestones::PromoteService.prepend_mod_with('Milestones::PromoteService') diff --git a/app/services/milestones/update_service.rb b/app/services/milestones/update_service.rb index 782c6bc3e35..b9a12a35d31 100644 --- a/app/services/milestones/update_service.rb +++ b/app/services/milestones/update_service.rb @@ -21,4 +21,4 @@ module Milestones end end -Milestones::UpdateService.prepend_if_ee('EE::Milestones::UpdateService') +Milestones::UpdateService.prepend_mod_with('Milestones::UpdateService') diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb index c6c04b63690..de54eb87cc0 100644 --- a/app/services/namespace_settings/update_service.rb +++ b/app/services/namespace_settings/update_service.rb @@ -35,4 +35,4 @@ module NamespaceSettings end end -NamespaceSettings::UpdateService.prepend_if_ee('EE::NamespaceSettings::UpdateService') +NamespaceSettings::UpdateService.prepend_mod_with('NamespaceSettings::UpdateService') diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb index eb81253bc08..61d5ed3bdf4 100644 --- a/app/services/namespaces/in_product_marketing_emails_service.rb +++ b/app/services/namespaces/in_product_marketing_emails_service.rb @@ -66,7 +66,6 @@ module Namespaces Experiment.add_group(:in_product_marketing_emails, variant: variant, group: group) end - # rubocop: disable CodeReuse/ActiveRecord def groups_for_track onboarding_progress_scope = OnboardingProgress .completed_actions_with_latest_in_range(completed_actions, range) @@ -75,9 +74,18 @@ module Namespaces # Filtering out sub-groups is a temporary fix to prevent calling # `.root_ancestor` on groups that are not root groups. # See https://gitlab.com/groups/gitlab-org/-/epics/5594 for more information. - Group.where(parent_id: nil).joins(:onboarding_progress).merge(onboarding_progress_scope) + Group + .top_most + .with_onboarding_progress + .merge(onboarding_progress_scope) + .merge(subscription_scope) + end + + def subscription_scope + {} end + # rubocop: disable CodeReuse/ActiveRecord def users_for_group(group) group.users .where(email_opted_in: true) @@ -136,3 +144,5 @@ module Namespaces end end end + +Namespaces::InProductMarketingEmailsService.prepend_mod diff --git a/app/services/namespaces/package_settings/update_service.rb b/app/services/namespaces/package_settings/update_service.rb index 0964963647a..cbadbe5c907 100644 --- a/app/services/namespaces/package_settings/update_service.rb +++ b/app/services/namespaces/package_settings/update_service.rb @@ -5,7 +5,10 @@ module Namespaces class UpdateService < BaseContainerService include Gitlab::Utils::StrongMemoize - ALLOWED_ATTRIBUTES = %i[maven_duplicates_allowed maven_duplicate_exception_regex].freeze + ALLOWED_ATTRIBUTES = %i[maven_duplicates_allowed + maven_duplicate_exception_regex + generic_duplicates_allowed + generic_duplicate_exception_regex].freeze def execute return ServiceResponse.error(message: 'Access Denied', http_status: 403) unless allowed? diff --git a/app/services/namespaces/statistics_refresher_service.rb b/app/services/namespaces/statistics_refresher_service.rb index c07b302839b..805060cdee9 100644 --- a/app/services/namespaces/statistics_refresher_service.rb +++ b/app/services/namespaces/statistics_refresher_service.rb @@ -9,7 +9,7 @@ module Namespaces root_storage_statistics.recalculate! rescue ActiveRecord::ActiveRecordError => e - raise RefresherError.new(e.message) + raise RefresherError, e.message end private diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index e63099a0820..542fafb901b 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -133,4 +133,4 @@ module Notes end end -Notes::CreateService.prepend_if_ee('EE::Notes::CreateService') +Notes::CreateService.prepend_mod_with('Notes::CreateService') diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb index 85f54a39add..c25b1ab0379 100644 --- a/app/services/notes/destroy_service.rb +++ b/app/services/notes/destroy_service.rb @@ -24,4 +24,4 @@ module Notes end end -Notes::DestroyService.prepend_if_ee('EE::Notes::DestroyService') +Notes::DestroyService.prepend_mod_with('Notes::DestroyService') diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 4f3b2000e9a..b7ccdbc1cff 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -44,4 +44,4 @@ module Notes end end -Notes::PostProcessService.prepend_if_ee('EE::Notes::PostProcessService') +Notes::PostProcessService.prepend_mod_with('Notes::PostProcessService') diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb index 36d9f1d7867..900ace24ab4 100644 --- a/app/services/notes/quick_actions_service.rb +++ b/app/services/notes/quick_actions_service.rb @@ -24,12 +24,12 @@ module Notes UPDATE_SERVICES end - def self.noteable_update_service(note) + def self.noteable_update_service_class(note) update_services[note.noteable_type] end def self.supported?(note) - !!noteable_update_service(note) + !!noteable_update_service_class(note) end def supported?(note) @@ -55,9 +55,23 @@ module Notes update_params[:spend_time][:note_id] = note.id end - self.class.noteable_update_service(note).new(note.resource_parent, current_user, update_params).execute(note.noteable) + noteable_update_service_class = self.class.noteable_update_service_class(note) + + # TODO: This conditional is necessary because we have not fully converted all possible + # noteable_update_service_class classes to use named arguments. See more details + # on the partial conversion at https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59182 + # Follow-on issue to address this is here: + # https://gitlab.com/gitlab-org/gitlab/-/issues/328734 + service = + if noteable_update_service_class.respond_to?(:constructor_container_arg) + noteable_update_service_class.new(**noteable_update_service_class.constructor_container_arg(note.resource_parent), current_user: current_user, params: update_params) + else + noteable_update_service_class.new(note.resource_parent, current_user, update_params) + end + + service.execute(note.noteable) end end end -Notes::QuickActionsService.prepend_if_ee('EE::Notes::QuickActionsService') +Notes::QuickActionsService.prepend_mod_with('Notes::QuickActionsService') diff --git a/app/services/notes/resolve_service.rb b/app/services/notes/resolve_service.rb index cf24795f050..75ce9e27c5b 100644 --- a/app/services/notes/resolve_service.rb +++ b/app/services/notes/resolve_service.rb @@ -5,7 +5,7 @@ module Notes def execute(note) note.resolve!(current_user) - ::MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(note.noteable) + ::MergeRequests::ResolvedDiscussionNotificationService.new(project: project, current_user: current_user).execute(note.noteable) end end end diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 76f9b6369b3..1cbb5916107 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -107,4 +107,4 @@ module Notes end end -Notes::UpdateService.prepend_if_ee('EE::Notes::UpdateService') +Notes::UpdateService.prepend_mod_with('Notes::UpdateService') diff --git a/app/services/notification_recipients/builder/base.rb b/app/services/notification_recipients/builder/base.rb index b41b969ad7c..e8f783136cc 100644 --- a/app/services/notification_recipients/builder/base.rb +++ b/app/services/notification_recipients/builder/base.rb @@ -100,7 +100,7 @@ module NotificationRecipients # Get project/group users with CUSTOM notification level # rubocop: disable CodeReuse/ActiveRecord def add_custom_notifications - return new_add_custom_notifications if Feature.enabled?(:notification_setting_recipient_refactor, project) + return new_add_custom_notifications if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml) user_ids = [] @@ -172,6 +172,8 @@ module NotificationRecipients # Get project users with WATCH notification level # rubocop: disable CodeReuse/ActiveRecord def project_watchers + return new_project_watchers if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml) + project_members_ids = user_ids_notifiable_on(project) user_ids_with_project_global = user_ids_notifiable_on(project, :global) @@ -184,16 +186,38 @@ module NotificationRecipients user_scope.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq) end + + def new_project_watchers + notification_by_sources = related_notification_settings_sources(:watch) + + return if notification_by_sources.blank? + + user_ids = NotificationSetting.from_union(notification_by_sources).select(:user_id) + + user_scope.where(id: user_ids) + end # rubocop: enable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord def group_watchers + return new_group_watchers if Feature.enabled?(:notification_setting_recipient_refactor, project, default_enabled: :yaml) + user_ids_with_group_global = user_ids_notifiable_on(group, :global) user_ids = user_ids_with_global_level_watch(user_ids_with_group_global) user_ids_with_group_setting = select_group_members_ids(group, [], user_ids_with_group_global, user_ids) user_scope.where(id: user_ids_with_group_setting) end + + def new_group_watchers + return [] unless group + + user_ids = group + .notification_settings + .where(source_or_global_setting_by_level_query(:watch)).select(:user_id) + + user_scope.where(id: user_ids) + end # rubocop: enable CodeReuse/ActiveRecord def add_subscribed_users diff --git a/app/services/notification_recipients/builder/default.rb b/app/services/notification_recipients/builder/default.rb index 19527ba84e6..58b0cd510c9 100644 --- a/app/services/notification_recipients/builder/default.rb +++ b/app/services/notification_recipients/builder/default.rb @@ -74,4 +74,4 @@ module NotificationRecipients end end -NotificationRecipients::Builder::Default.prepend_if_ee('EE::NotificationRecipients::Builder::Default') +NotificationRecipients::Builder::Default.prepend_mod_with('NotificationRecipients::Builder::Default') diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 6f1f3309ad9..9dfcfe748da 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -384,6 +384,7 @@ class NotificationService def send_service_desk_notification(note) return unless note.noteable_type == 'Issue' + return if note.confidential issue = note.noteable recipients = issue.email_participants_emails @@ -875,4 +876,4 @@ class NotificationService end end -NotificationService.prepend_if_ee('EE::NotificationService') +NotificationService.prepend_mod_with('NotificationService') diff --git a/app/services/packages/debian/extract_changes_metadata_service.rb b/app/services/packages/debian/extract_changes_metadata_service.rb index eb5baa7e53f..43a4db5bdfc 100644 --- a/app/services/packages/debian/extract_changes_metadata_service.rb +++ b/app/services/packages/debian/extract_changes_metadata_service.rb @@ -20,7 +20,7 @@ module Packages files: files } rescue ActiveModel::ValidationError => e - raise ExtractionError.new(e.message) + raise ExtractionError, e.message end private @@ -41,10 +41,10 @@ module Packages def files strong_memoize(:files) do - raise ExtractionError.new("is not a changes file") unless file_type == :changes - raise ExtractionError.new("Files field is missing") if fields['Files'].blank? - raise ExtractionError.new("Checksums-Sha1 field is missing") if fields['Checksums-Sha1'].blank? - raise ExtractionError.new("Checksums-Sha256 field is missing") if fields['Checksums-Sha256'].blank? + raise ExtractionError, "is not a changes file" unless file_type == :changes + raise ExtractionError, "Files field is missing" if fields['Files'].blank? + raise ExtractionError, "Checksums-Sha1 field is missing" if fields['Checksums-Sha1'].blank? + raise ExtractionError, "Checksums-Sha256 field is missing" if fields['Checksums-Sha256'].blank? init_entries_from_files entries_from_checksums_sha1 @@ -73,8 +73,8 @@ module Packages each_lines_for('Checksums-Sha1') do |line| sha1sum, size, filename = line.split entry = @entries[filename] - raise ExtractionError.new("#{filename} is listed in Checksums-Sha1 but not in Files") unless entry - raise ExtractionError.new("Size for #{filename} in Files and Checksums-Sha1 differ") unless entry.size == size.to_i + raise ExtractionError, "#{filename} is listed in Checksums-Sha1 but not in Files" unless entry + raise ExtractionError, "Size for #{filename} in Files and Checksums-Sha1 differ" unless entry.size == size.to_i entry.sha1sum = sha1sum end @@ -84,8 +84,8 @@ module Packages each_lines_for('Checksums-Sha256') do |line| sha256sum, size, filename = line.split entry = @entries[filename] - raise ExtractionError.new("#{filename} is listed in Checksums-Sha256 but not in Files") unless entry - raise ExtractionError.new("Size for #{filename} in Files and Checksums-Sha256 differ") unless entry.size == size.to_i + raise ExtractionError, "#{filename} is listed in Checksums-Sha256 but not in Files" unless entry + raise ExtractionError, "Size for #{filename} in Files and Checksums-Sha256 differ" unless entry.size == size.to_i entry.sha256sum = sha256sum end @@ -104,7 +104,7 @@ module Packages entry.package_file = ::Packages::PackageFileFinder.new(@package_file.package, filename).execute! entry.validate! rescue ActiveRecord::RecordNotFound - raise ExtractionError.new("#{filename} is listed in Files but was not uploaded") + raise ExtractionError, "#{filename} is listed in Files but was not uploaded" end end end diff --git a/app/services/packages/debian/extract_metadata_service.rb b/app/services/packages/debian/extract_metadata_service.rb index 015f472c7c9..f94587919b9 100644 --- a/app/services/packages/debian/extract_metadata_service.rb +++ b/app/services/packages/debian/extract_metadata_service.rb @@ -12,7 +12,7 @@ module Packages end def execute - raise ExtractionError.new('invalid package file') unless valid_package_file? + raise ExtractionError, 'invalid package file' unless valid_package_file? extract_metadata end diff --git a/app/services/packages/debian/generate_distribution_key_service.rb b/app/services/packages/debian/generate_distribution_key_service.rb new file mode 100644 index 00000000000..28c97c7681e --- /dev/null +++ b/app/services/packages/debian/generate_distribution_key_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Packages + module Debian + class GenerateDistributionKeyService + include Gitlab::Utils::StrongMemoize + + def initialize(current_user:, params: {}) + @current_user = current_user + @params = params + end + + def execute + raise ArgumentError, 'Please provide a user' unless current_user.is_a?(User) + + generate_key + end + + private + + attr_reader :current_user, :params + + def passphrase + strong_memoize(:passphrase) do + params[:passphrase] || ::User.random_password + end + end + + def pinentry_script_content + escaped_passphrase = Shellwords.escape(passphrase) + + <<~EOF + #!/bin/sh + + echo OK Pleased to meet you + + while read -r cmd; do + case "$cmd" in + GETPIN) echo D #{escaped_passphrase}; echo OK;; + *) echo OK;; + esac + done + EOF + end + + def using_pinentry + Gitlab::Gpg.using_tmp_keychain do + home_dir = Gitlab::Gpg.current_home_dir + + File.write("#{home_dir}/pinentry.sh", pinentry_script_content, mode: 'w', perm: 0755) + + File.write("#{home_dir}/gpg-agent.conf", "pinentry-program #{home_dir}/pinentry.sh\n", mode: 'w') + + GPGME::Ctx.new(armor: true, offline: true) do |ctx| + yield ctx + end + end + end + + def generate_key_params + # https://www.gnupg.org/documentation/manuals/gnupg/Unattended-GPG-key-generation.html + '<GnupgKeyParms format="internal">' + "\n" + + { + 'Key-Type': params[:key_type] || 'RSA', + 'Key-Length': params[:key_length] || 4096, + 'Key-Usage': params[:key_usage] || 'sign', + 'Name-Real': params[:name_real] || 'GitLab Debian repository', + 'Name-Email': params[:name_email] || Gitlab.config.gitlab.email_reply_to, + 'Name-Comment': params[:name_comment] || 'GitLab Debian repository automatic signing key', + 'Expire-Date': params[:expire_date] || 0, + 'Passphrase': passphrase + }.map { |k, v| "#{k}: #{v}\n" }.join + + '</GnupgKeyParms>' + end + + def generate_key + using_pinentry do |ctx| + # Generate key + ctx.generate_key generate_key_params + + key = ctx.keys.first # rubocop:disable Gitlab/KeysFirstAndValuesFirst + fingerprint = key.fingerprint + + # Export private key + data = GPGME::Data.new + ctx.export_keys fingerprint, data, GPGME::EXPORT_MODE_SECRET + data.seek 0 + private_key = data.read + + # Export public key + data = GPGME::Data.new + ctx.export_keys fingerprint, data + data.seek 0 + public_key = data.read + + { + private_key: private_key, + public_key: public_key, + passphrase: passphrase, + fingerprint: fingerprint + } + end + end + end + end +end diff --git a/app/services/packages/debian/generate_distribution_service.rb b/app/services/packages/debian/generate_distribution_service.rb new file mode 100644 index 00000000000..67348af1a49 --- /dev/null +++ b/app/services/packages/debian/generate_distribution_service.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +module Packages + module Debian + class GenerateDistributionService + include Gitlab::Utils::StrongMemoize + include ExclusiveLeaseGuard + + # used by ExclusiveLeaseGuard + DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze + + # From https://salsa.debian.org/ftp-team/dak/-/blob/991aaa27a7f7aa773bb9c0cf2d516e383d9cffa0/setup/core-init.d/080_metadatakeys#L9 + BINARIES_METADATA = %w( + Package + Source + Binary + Version + Essential + Installed-Size + Maintainer + Uploaders + Original-Maintainer + Build-Depends + Build-Depends-Indep + Build-Conflicts + Build-Conflicts-Indep + Architecture + Standards-Version + Format + Files + Dm-Upload-Allowed + Vcs-Browse + Vcs-Hg + Vcs-Darcs + Vcs-Svn + Vcs-Git + Vcs-Browser + Vcs-Arch + Vcs-Bzr + Vcs-Mtn + Vcs-Cvs + Checksums-Sha256 + Checksums-Sha1 + Replaces + Provides + Depends + Pre-Depends + Recommends + Suggests + Enhances + Conflicts + Breaks + Description + Origin + Bugs + Multi-Arch + Homepage + Tag + Package-Type + Installer-Menu-Item + ).freeze + + def initialize(distribution) + @distribution = distribution + @last_generated_at = nil + @md5sum = [] + @sha256 = [] + end + + def execute + try_obtain_lease do + @distribution.transaction do + @last_generated_at = @distribution.component_files.maximum(:created_at) + generate_component_files + generate_release + destroy_old_component_files + end + end + end + + private + + def generate_component_files + @distribution.components.ordered_by_name.each do |component| + @distribution.architectures.ordered_by_name.each do |architecture| + generate_component_file(component, :packages, architecture, :deb) + end + end + end + + def generate_component_file(component, component_file_type, architecture, package_file_type) + paragraphs = @distribution.package_files + .preload_debian_file_metadata + .with_debian_component_name(component.name) + .with_debian_architecture_name(architecture.name) + .with_debian_file_type(package_file_type) + .find_each + .map(&method(:package_stanza_from_fields)) + create_component_file(component, component_file_type, architecture, package_file_type, paragraphs.join("\n")) + end + + def package_stanza_from_fields(package_file) + [ + BINARIES_METADATA.map do |metadata_key| + rfc822_field(metadata_key, package_file.debian_fields[metadata_key]) + end, + rfc822_field('Section', package_file.debian_fields['Section'] || 'misc'), + rfc822_field('Priority', package_file.debian_fields['Priority'] || 'extra'), + rfc822_field('Filename', package_filename(package_file)), + rfc822_field('Size', package_file.size), + rfc822_field('MD5sum', package_file.file_md5), + rfc822_field('SHA256', package_file.file_sha256) + ].flatten.compact.join('') + end + + def package_filename(package_file) + letter = package_file.package.name.start_with?('lib') ? package_file.package.name[0..3] : package_file.package.name[0] + "#{pool_prefix(package_file)}/#{letter}/#{package_file.package.name}/#{package_file.file_name}" + end + + def pool_prefix(package_file) + case @distribution + when ::Packages::Debian::GroupDistribution + "pool/#{@distribution.codename}/#{package_file.package.project_id}" + else + "pool/#{@distribution.codename}/#{@distribution.container_id}" + end + end + + def create_component_file(component, component_file_type, architecture, package_file_type, content) + component_file = component.files.create!( + file_type: component_file_type, + architecture: architecture, + compression_type: nil, + file: CarrierWaveStringFile.new(content), + file_md5: Digest::MD5.hexdigest(content), + file_sha256: Digest::SHA256.hexdigest(content) + ) + @md5sum.append(" #{component_file.file_md5} #{component_file.size.to_s.rjust(8)} #{component_file.relative_path}") + @sha256.append(" #{component_file.file_sha256} #{component_file.size.to_s.rjust(8)} #{component_file.relative_path}") + end + + def generate_release + @distribution.file = CarrierWaveStringFile.new(release_header + release_sums) + @distribution.updated_at = release_date + @distribution.save! + end + + def release_header + strong_memoize(:release_header) do + [ + %w[origin label suite version codename].map do |attribute| + rfc822_field(attribute.capitalize, @distribution.attributes[attribute]) + end, + rfc822_field('Date', release_date.to_formatted_s(:rfc822)), + valid_until_field, + rfc822_field('NotAutomatic', !@distribution.automatic, !@distribution.automatic), + rfc822_field('ButAutomaticUpgrades', @distribution.automatic_upgrades, !@distribution.automatic && @distribution.automatic_upgrades), + rfc822_field('Architectures', @distribution.architectures.map { |architecture| architecture.name }.sort.join(' ')), + rfc822_field('Components', @distribution.components.map { |component| component.name }.sort.join(' ')), + rfc822_field('Description', @distribution.description) + ].flatten.compact.join('') + end + end + + def release_date + strong_memoize(:release_date) do + Time.now.utc + end + end + + def release_sums + ["MD5Sum:", @md5sum, "SHA256:", @sha256].flatten.compact.join("\n") + "\n" + end + + def rfc822_field(name, value, condition = true) + return unless condition + return if value.blank? + + "#{name}: #{value.to_s.gsub("\n\n", "\n.\n").gsub("\n", "\n ")}\n" + end + + def valid_until_field + return unless @distribution.valid_time_duration_seconds + + rfc822_field('Valid-Until', release_date.since(@distribution.valid_time_duration_seconds).to_formatted_s(:rfc822)) + end + + def destroy_old_component_files + # Only keep the last generation and one hour before + return if @last_generated_at.nil? + + @distribution.component_files.created_before(@last_generated_at - 1.hour).destroy_all # rubocop:disable Cop/DestroyAll + end + + # used by ExclusiveLeaseGuard + def lease_key + "packages:debian:generate_distribution_service:distribution:#{@distribution.id}" + end + + # used by ExclusiveLeaseGuard + def lease_timeout + DEFAULT_LEASE_TIMEOUT + end + end + end +end diff --git a/app/services/packages/generic/create_package_file_service.rb b/app/services/packages/generic/create_package_file_service.rb index 1451a022a39..42a191fb415 100644 --- a/app/services/packages/generic/create_package_file_service.rb +++ b/app/services/packages/generic/create_package_file_service.rb @@ -23,6 +23,10 @@ module Packages .new(project, current_user, package_params) .execute + unless Namespace::PackageSetting.duplicates_allowed?(package) + raise ::Packages::DuplicatePackageError if target_file_is_duplicate?(package) + end + package.update_column(:status, params[:status]) if params[:status] && params[:status] != package.status package.build_infos.safe_find_or_create_by!(pipeline: params[:build].pipeline) if params[:build].present? @@ -40,6 +44,10 @@ module Packages ::Packages::CreatePackageFileService.new(package, file_params).execute end + + def target_file_is_duplicate?(package) + package.package_files.with_file_name(params[:file_name]).exists? + end end end end diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb index a6cffa3038c..c7ffd468864 100644 --- a/app/services/packages/maven/find_or_create_package_service.rb +++ b/app/services/packages/maven/find_or_create_package_service.rb @@ -6,7 +6,7 @@ module Packages def execute package = - ::Packages::Maven::PackageFinder.new(params[:path], current_user, project: project) + ::Packages::Maven::PackageFinder.new(current_user, project, path: params[:path]) .execute unless Namespace::PackageSetting.duplicates_allowed?(package) diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb index 59125669f7d..dd5f1bcc936 100644 --- a/app/services/packages/nuget/metadata_extraction_service.rb +++ b/app/services/packages/nuget/metadata_extraction_service.rb @@ -26,7 +26,7 @@ module Packages end def execute - raise ExtractionError.new('invalid package file') unless valid_package_file? + raise ExtractionError, 'invalid package file' unless valid_package_file? extract_metadata(nuspec_file) end @@ -94,8 +94,8 @@ module Packages Zip::File.open(file_path) do |zip_file| entry = zip_file.glob('*.nuspec').first - raise ExtractionError.new('nuspec file not found') unless entry - raise ExtractionError.new('nuspec file too big') if entry.size > MAX_FILE_SIZE + raise ExtractionError, 'nuspec file not found' unless entry + raise ExtractionError, 'nuspec file too big' if entry.size > MAX_FILE_SIZE entry.get_input_stream.read end diff --git a/app/services/packages/nuget/search_service.rb b/app/services/packages/nuget/search_service.rb index 1eead1e62b3..fea424b3aa8 100644 --- a/app/services/packages/nuget/search_service.rb +++ b/app/services/packages/nuget/search_service.rb @@ -103,6 +103,7 @@ module Packages def nuget_packages Packages::Package.nuget + .displayable .has_version .without_nuget_temporary_name end diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb index 1bcab00bd92..8210072eab3 100644 --- a/app/services/packages/nuget/update_package_from_metadata_service.rb +++ b/app/services/packages/nuget/update_package_from_metadata_service.rb @@ -16,7 +16,7 @@ module Packages end def execute - raise InvalidMetadataError.new('package name and/or package version not found in metadata') unless valid_metadata? + raise InvalidMetadataError, 'package name and/or package version not found in metadata' unless valid_metadata? try_obtain_lease do @package_file.transaction do @@ -33,7 +33,7 @@ module Packages end end rescue ActiveRecord::RecordInvalid => e - raise InvalidMetadataError.new(e.message) + raise InvalidMetadataError, e.message end private @@ -45,7 +45,7 @@ module Packages ::Packages::UpdateTagsService .new(package, package_tags) .execute - rescue => e + rescue StandardError => e raise InvalidMetadataError, e.message end diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb index cb8d9559dc9..b988c191734 100644 --- a/app/services/packages/pypi/create_package_service.rb +++ b/app/services/packages/pypi/create_package_service.rb @@ -13,7 +13,7 @@ module Packages ) unless meta.valid? - raise ActiveRecord::RecordInvalid.new(meta) + raise ActiveRecord::RecordInvalid, meta end Packages::Pypi::Metadatum.upsert(meta.attributes) diff --git a/app/services/packages/rubygems/process_gem_service.rb b/app/services/packages/rubygems/process_gem_service.rb index 59bf2a1ec28..109c87a0444 100644 --- a/app/services/packages/rubygems/process_gem_service.rb +++ b/app/services/packages/rubygems/process_gem_service.rb @@ -16,6 +16,7 @@ module Packages end def execute + raise ExtractionError, 'Gem was not processed - package_file is not set' unless package_file return success if process_gem error('Gem was not processed') @@ -26,8 +27,6 @@ module Packages attr_reader :package_file def process_gem - return false unless package_file - try_obtain_lease do package.transaction do rename_package_and_set_version @@ -106,8 +105,8 @@ module Packages Packages::PackageFile.find(package_file.id).file.use_file do |file_path| Gem::Package.new(File.open(file_path)) end - rescue - raise ExtractionError.new('Unable to read gem file') + rescue StandardError + raise ExtractionError, 'Unable to read gem file' end # used by ExclusiveLeaseGuard diff --git a/app/services/packages/terraform_module/create_package_service.rb b/app/services/packages/terraform_module/create_package_service.rb new file mode 100644 index 00000000000..fc376c70b00 --- /dev/null +++ b/app/services/packages/terraform_module/create_package_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Packages + module TerraformModule + class CreatePackageService < ::Packages::CreatePackageService + include Gitlab::Utils::StrongMemoize + + def execute + return error('Version is empty.', 400) if params[:module_version].blank? + return error('Package already exists.', 403) if current_package_exists_elsewhere? + return error('Package version already exists.', 403) if current_package_version_exists? + return error('File is too large.', 400) if file_size_exceeded? + + ActiveRecord::Base.transaction { create_terraform_module_package! } + end + + private + + def create_terraform_module_package! + package = create_package!(:terraform_module, name: name, version: params[:module_version]) + + ::Packages::CreatePackageFileService.new(package, file_params).execute + + package + end + + def current_package_exists_elsewhere? + ::Packages::Package + .for_projects(project.root_namespace.all_projects.id_not_in(project.id)) + .with_package_type(:terraform_module) + .with_name(name) + .exists? + end + + def current_package_version_exists? + project.packages + .with_package_type(:terraform_module) + .with_name(name) + .with_version(params[:module_version]) + .exists? + end + + def name + strong_memoize(:name) do + "#{params[:module_name]}/#{params[:module_system]}" + end + end + + def file_name + strong_memoize(:file_name) do + "#{params[:module_name]}-#{params[:module_system]}-#{params[:module_version]}.tgz" + end + end + + def file_params + { + file: params[:file], + size: params[:file].size, + file_sha256: params[:file].sha256, + file_name: file_name, + build: params[:build] + } + end + + def file_size_exceeded? + project.actual_limits.exceeded?(:generic_packages_max_file_size, params[:file].size) + end + end + end +end diff --git a/app/services/pages/migrate_from_legacy_storage_service.rb b/app/services/pages/migrate_from_legacy_storage_service.rb index b6aa08bba01..d102f93e863 100644 --- a/app/services/pages/migrate_from_legacy_storage_service.rb +++ b/app/services/pages/migrate_from_legacy_storage_service.rb @@ -59,7 +59,7 @@ module Pages end @logger.info(message: "Pages legacy storage migration: batch processed", migrated: @migrated, errored: @errored) - rescue => e + rescue StandardError => e # This method should never raise exception otherwise all threads might be killed # and this will result in queue starving (and deadlock) Gitlab::ErrorTracking.track_exception(e) @@ -81,7 +81,7 @@ module Pages @logger.error(message: "Pages legacy storage migration: project failed to be migrated: #{result[:message]}", project_id: project.id, pages_path: project.pages_path, duration: time.round(2)) @counters_lock.synchronize { @errored += 1 } end - rescue => e + rescue StandardError => e @counters_lock.synchronize { @errored += 1 } @logger.error(message: "Pages legacy storage migration: project failed to be migrated: #{result[:message]}", project_id: project&.id, pages_path: project&.pages_path) Gitlab::ErrorTracking.track_exception(e, project_id: project&.id) diff --git a/app/services/pages/zip_directory_service.rb b/app/services/pages/zip_directory_service.rb index 6cb79452e1b..895614a84a0 100644 --- a/app/services/pages/zip_directory_service.rb +++ b/app/services/pages/zip_directory_service.rb @@ -31,7 +31,7 @@ module Pages end success(archive_path: output_file, entries_count: entries_count) - rescue => e + rescue StandardError => e FileUtils.rm_f(output_file) if output_file raise e end diff --git a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb index e14241158a6..ca5df4ce017 100644 --- a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb +++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb @@ -66,7 +66,7 @@ module PagesDomains project_id: pages_domain.project_id, pages_domain: pages_domain.domain ) - rescue => e + rescue StandardError => e # getting authorizations is an additional network request which can raise errors Gitlab::ErrorTracking.track_exception(e) end diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb index 93a0135669f..7555ba26768 100644 --- a/app/services/personal_access_tokens/create_service.rb +++ b/app/services/personal_access_tokens/create_service.rb @@ -45,4 +45,4 @@ module PersonalAccessTokens end end -PersonalAccessTokens::CreateService.prepend_if_ee('EE::PersonalAccessTokens::CreateService') +PersonalAccessTokens::CreateService.prepend_mod_with('PersonalAccessTokens::CreateService') diff --git a/app/services/personal_access_tokens/revoke_service.rb b/app/services/personal_access_tokens/revoke_service.rb index 34d542acab1..0275d03bcc9 100644 --- a/app/services/personal_access_tokens/revoke_service.rb +++ b/app/services/personal_access_tokens/revoke_service.rb @@ -41,4 +41,4 @@ module PersonalAccessTokens end end -PersonalAccessTokens::RevokeService.prepend_if_ee('EE::PersonalAccessTokens::RevokeService') +PersonalAccessTokens::RevokeService.prepend_mod_with('PersonalAccessTokens::RevokeService') diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb index 58d1bfbf835..28ccace62e5 100644 --- a/app/services/pod_logs/elasticsearch_service.rb +++ b/app/services/pod_logs/elasticsearch_service.rb @@ -24,7 +24,7 @@ module PodLogs end def get_raw_pods(result) - client = cluster&.application_elastic_stack&.elasticsearch_client + client = cluster&.elasticsearch_client return error(_('Unable to connect to Elasticsearch')) unless client result[:raw_pods] = ::Gitlab::Elasticsearch::Logs::Pods.new(client).pods(namespace) @@ -66,11 +66,9 @@ module PodLogs end def pod_logs(result) - client = cluster&.application_elastic_stack&.elasticsearch_client + client = cluster&.elasticsearch_client return error(_('Unable to connect to Elasticsearch')) unless client - chart_above_v2 = cluster.application_elastic_stack.chart_above_v2? - response = ::Gitlab::Elasticsearch::Logs::Lines.new(client).pod_logs( namespace, pod_name: result[:pod_name], @@ -79,7 +77,7 @@ module PodLogs start_time: result[:start_time], end_time: result[:end_time], cursor: result[:cursor], - chart_above_v2: chart_above_v2 + chart_above_v2: cluster.elastic_stack_adapter.chart_above_v2? ) result.merge!(response) diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb index 3dc8fd8929a..faacabbb16c 100644 --- a/app/services/post_receive_service.rb +++ b/app/services/post_receive_service.rb @@ -56,7 +56,7 @@ class PostReceiveService end service = ::MergeRequests::PushOptionsHandlerService.new( - project, user, changes, push_options + project: project, current_user: user, changes: changes, push_options: push_options ).execute if service.errors.present? @@ -72,7 +72,7 @@ class PostReceiveService def merge_request_urls return [] unless repository&.repo_type&.project? - ::MergeRequests::GetUrlsService.new(project).execute(params[:changes]) + ::MergeRequests::GetUrlsService.new(project: project).execute(params[:changes]) end private @@ -98,4 +98,4 @@ class PostReceiveService end end -PostReceiveService.prepend_if_ee('EE::PostReceiveService') +PostReceiveService.prepend_mod_with('PostReceiveService') diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index afe2651b11a..af9c338b59e 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -68,4 +68,4 @@ class PreviewMarkdownService < BaseService end end -PreviewMarkdownService.prepend_if_ee('EE::PreviewMarkdownService') +PreviewMarkdownService.prepend_mod_with('PreviewMarkdownService') diff --git a/app/services/projects/after_rename_service.rb b/app/services/projects/after_rename_service.rb index a2cdb87e631..6d389035922 100644 --- a/app/services/projects/after_rename_service.rb +++ b/app/services/projects/after_rename_service.rb @@ -49,10 +49,8 @@ module Projects def first_ensure_no_registry_tags_are_present return unless project.has_container_registry_tags? - raise RenameFailedError.new( - "Project #{full_path_before} cannot be renamed because images are " \ + raise RenameFailedError, "Project #{full_path_before} cannot be renamed because images are " \ "present in its container registry" - ) end def expire_caches_before_rename @@ -144,9 +142,9 @@ module Projects Gitlab::AppLogger.error(error) - raise RenameFailedError.new(error) + raise RenameFailedError, error end end end -Projects::AfterRenameService.prepend_if_ee('EE::Projects::AfterRenameService') +Projects::AfterRenameService.prepend_mod_with('Projects::AfterRenameService') diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 68086f636b7..55f16aa3e3d 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -39,4 +39,4 @@ module Projects end end -Projects::AutocompleteService.prepend_if_ee('EE::Projects::AutocompleteService') +Projects::AutocompleteService.prepend_mod_with('Projects::AutocompleteService') diff --git a/app/services/projects/cleanup_service.rb b/app/services/projects/cleanup_service.rb index 7bcaee75813..5eafa5f9b29 100644 --- a/app/services/projects/cleanup_service.rb +++ b/app/services/projects/cleanup_service.rb @@ -108,4 +108,4 @@ module Projects end end -Projects::CleanupService.prepend_if_ee('EE::Projects::CleanupService') +Projects::CleanupService.prepend_mod_with('Projects::CleanupService') diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb index 3c66ff709c9..48dda09da71 100644 --- a/app/services/projects/create_from_template_service.rb +++ b/app/services/projects/create_from_template_service.rb @@ -58,4 +58,4 @@ module Projects end end -Projects::CreateFromTemplateService.prepend_if_ee('EE::Projects::CreateFromTemplateService') +Projects::CreateFromTemplateService.prepend_mod_with('Projects::CreateFromTemplateService') diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 5fb0bda912e..97ea7d87545 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -40,7 +40,7 @@ module Projects if namespace_id # Find matching namespace and check if it allowed # for current user if namespace_id passed. - unless allowed_namespace?(current_user, namespace_id) + unless current_user.can?(:create_projects, project_namespace) @project.namespace_id = nil deny_namespace return @project @@ -72,7 +72,7 @@ module Projects rescue ActiveRecord::RecordInvalid => e message = "Unable to save #{e.inspect}: #{e.record.errors.full_messages.join(", ")}" fail(error: message) - rescue => e + rescue StandardError => e @project.errors.add(:base, e.message) if @project fail(error: e.message) end @@ -83,13 +83,6 @@ module Projects @project.errors.add(:namespace, "is not valid") end - # rubocop: disable CodeReuse/ActiveRecord - def allowed_namespace?(user, namespace_id) - namespace = Namespace.find_by(id: namespace_id) - current_user.can?(:create_projects, namespace) - end - # rubocop: enable CodeReuse/ActiveRecord - def after_create_actions log_info("#{@project.owner.name} created a new project \"#{@project.full_name}\"") @@ -156,7 +149,7 @@ module Projects def create_readme commit_attrs = { - branch_name: @project.default_branch || 'master', + branch_name: @project.default_branch_or_main, commit_message: 'Initial commit', file_path: 'README.md', file_content: "# #{@project.name}\n\n#{@project.description}" @@ -174,7 +167,7 @@ module Projects @project.create_or_update_import_data(data: @import_data[:data], credentials: @import_data[:credentials]) if @import_data if @project.save - Service.create_from_active_default_integrations(@project, :project_id, with_templates: true) + Integration.create_from_active_default_integrations(@project, :project_id, with_templates: true) @project.create_labels unless @project.gitlab_project_import? @@ -271,7 +264,7 @@ module Projects end end -Projects::CreateService.prepend_if_ee('EE::Projects::CreateService') +Projects::CreateService.prepend_mod_with('Projects::CreateService') # Measurable should be at the bottom of the ancestor chain, so it will measure execution of EE::Projects::CreateService as well Projects::CreateService.prepend(Measurable) diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 4ba48f74273..0682f3013d4 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -41,7 +41,7 @@ module Projects current_user.invalidate_personal_projects_count true - rescue => error + rescue StandardError => error attempt_rollback(project, error.message) false rescue Exception => error # rubocop:disable Lint/RescueException @@ -116,6 +116,7 @@ module Projects log_destroy_event trash_relation_repositories! trash_project_repositories! + destroy_web_hooks! if Feature.enabled?(:destroy_webhooks_before_the_project, project, default_enabled: :yaml) # Rails attempts to load all related records into memory before # destroying: https://github.com/rails/rails/issues/22510 @@ -131,6 +132,23 @@ module Projects log_info("Attempting to destroy #{project.full_path} (#{project.id})") end + # The project can have multiple webhooks with hundreds of thousands of web_hook_logs. + # By default, they are removed with "DELETE CASCADE" option defined via foreign_key. + # But such queries can exceed the statement_timeout limit and fail to delete the project. + # (see https://gitlab.com/gitlab-org/gitlab/-/issues/26259) + # + # To prevent that we use WebHooks::DestroyService. It deletes logs in batches and + # produces smaller and faster queries to the database. + def destroy_web_hooks! + project.hooks.find_each do |web_hook| + result = ::WebHooks::DestroyService.new(current_user).sync_destroy(web_hook) + + unless result[:status] == :success + raise_error(s_('DeleteProject|Failed to remove webhooks. Please try again or contact administrator.')) + end + end + end + def remove_registry_tags return true unless Gitlab.config.registry.enabled return false unless remove_legacy_registry_tags @@ -156,7 +174,7 @@ module Projects end def raise_error(message) - raise DestroyError.new(message) + raise DestroyError, message end def flush_caches(project) @@ -165,4 +183,4 @@ module Projects end end -Projects::DestroyService.prepend_if_ee('EE::Projects::DestroyService') +Projects::DestroyService.prepend_mod_with('Projects::DestroyService') diff --git a/app/services/projects/disable_deploy_key_service.rb b/app/services/projects/disable_deploy_key_service.rb index 9fb2e3398b2..e0f309875de 100644 --- a/app/services/projects/disable_deploy_key_service.rb +++ b/app/services/projects/disable_deploy_key_service.rb @@ -12,4 +12,4 @@ module Projects end end -Projects::DisableDeployKeyService.prepend_if_ee('EE::Projects::DisableDeployKeyService') +Projects::DisableDeployKeyService.prepend_mod_with('Projects::DisableDeployKeyService') diff --git a/app/services/projects/enable_deploy_key_service.rb b/app/services/projects/enable_deploy_key_service.rb index 0a24137bd61..581a6cc0ade 100644 --- a/app/services/projects/enable_deploy_key_service.rb +++ b/app/services/projects/enable_deploy_key_service.rb @@ -27,4 +27,4 @@ module Projects end end -Projects::EnableDeployKeyService.prepend_if_ee('EE::Projects::EnableDeployKeyService') +Projects::EnableDeployKeyService.prepend_mod_with('Projects::EnableDeployKeyService') diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb index 38f0e2f7c1a..63a41d172ea 100644 --- a/app/services/projects/gitlab_projects_import_service.rb +++ b/app/services/projects/gitlab_projects_import_service.rb @@ -77,4 +77,4 @@ module Projects end end -Projects::GitlabProjectsImportService.prepend_if_ee('EE::Projects::GitlabProjectsImportService') +Projects::GitlabProjectsImportService.prepend_mod_with('Projects::GitlabProjectsImportService') diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb index 3262839e246..d8fa2f36fcc 100644 --- a/app/services/projects/group_links/create_service.rb +++ b/app/services/projects/group_links/create_service.rb @@ -44,4 +44,4 @@ module Projects end end -Projects::GroupLinks::CreateService.prepend_if_ee('EE::Projects::GroupLinks::CreateService') +Projects::GroupLinks::CreateService.prepend_mod_with('Projects::GroupLinks::CreateService') diff --git a/app/services/projects/group_links/destroy_service.rb b/app/services/projects/group_links/destroy_service.rb index 229191e41f6..bfe704cd780 100644 --- a/app/services/projects/group_links/destroy_service.rb +++ b/app/services/projects/group_links/destroy_service.rb @@ -20,4 +20,4 @@ module Projects end end -Projects::GroupLinks::DestroyService.prepend_if_ee('EE::Projects::GroupLinks::DestroyService') +Projects::GroupLinks::DestroyService.prepend_mod_with('Projects::GroupLinks::DestroyService') diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb index 3d9d03c4a95..023f8494d99 100644 --- a/app/services/projects/hashed_storage/migrate_attachments_service.rb +++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb @@ -64,4 +64,4 @@ module Projects end end -Projects::HashedStorage::MigrateAttachmentsService.prepend_if_ee('EE::Projects::HashedStorage::MigrateAttachmentsService') +Projects::HashedStorage::MigrateAttachmentsService.prepend_mod_with('Projects::HashedStorage::MigrateAttachmentsService') diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb index adc7e38e4d5..c7989e04607 100644 --- a/app/services/projects/hashed_storage/migrate_repository_service.rb +++ b/app/services/projects/hashed_storage/migrate_repository_service.rb @@ -52,4 +52,4 @@ module Projects end end -Projects::HashedStorage::MigrateRepositoryService.prepend_if_ee('EE::Projects::HashedStorage::MigrateRepositoryService') +Projects::HashedStorage::MigrateRepositoryService.prepend_mod_with('Projects::HashedStorage::MigrateRepositoryService') diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb deleted file mode 100644 index b5589d556aa..00000000000 --- a/app/services/projects/housekeeping_service.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# This is a compatibility class to avoid calling a non-existent -# class from sidekiq during deployment. -# -# We're deploying the rename of this class in 13.9. Nevertheless, -# we cannot remove this class entirely because there can be jobs -# referencing it. -# -# We can get rid of this class in 13.10 -# https://gitlab.com/gitlab-org/gitlab/-/issues/297580 -# -module Projects - class HousekeepingService < ::Repositories::HousekeepingService - end -end diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index c2a8db7b657..64c0f1ff4ac 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -112,7 +112,7 @@ module Projects def notify_error! notify_error - raise Gitlab::ImportExport::Error.new(shared.errors.to_sentence) + raise Gitlab::ImportExport::Error, shared.errors.to_sentence end def notify_success diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb index b4abb5b6df7..b5288aad6f0 100644 --- a/app/services/projects/import_service.rb +++ b/app/services/projects/import_service.rb @@ -29,7 +29,7 @@ module Projects Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type) error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: e.message }) - rescue => e + rescue StandardError => e message = Projects::ImportErrorFilter.filter_message(e.message) Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path, importer: project.import_type) @@ -149,7 +149,7 @@ module Projects end end -Projects::ImportService.prepend_if_ee('EE::Projects::ImportService') +Projects::ImportService.prepend_mod_with('Projects::ImportService') # Measurable should be at the bottom of the ancestor chain, so it will measure execution of EE::Projects::ImportService as well Projects::ImportService.prepend(Measurable) diff --git a/app/services/projects/lfs_pointers/lfs_import_service.rb b/app/services/projects/lfs_pointers/lfs_import_service.rb index 2afcce7099b..3fc82f2c410 100644 --- a/app/services/projects/lfs_pointers/lfs_import_service.rb +++ b/app/services/projects/lfs_pointers/lfs_import_service.rb @@ -16,7 +16,7 @@ module Projects end success - rescue => e + rescue StandardError => e error(e.message) end end diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb index 7dfe7fffa1b..c0734171ee5 100644 --- a/app/services/projects/operations/update_service.rb +++ b/app/services/projects/operations/update_service.rb @@ -135,4 +135,4 @@ module Projects end end -Projects::Operations::UpdateService.prepend_if_ee('::EE::Projects::Operations::UpdateService') +Projects::Operations::UpdateService.prepend_mod_with('Projects::Operations::UpdateService') diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index 93165a58470..db640a54745 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -63,7 +63,7 @@ module Projects def valid_alert_manager_token?(token, integration) valid_for_manual?(token) || valid_for_alerts_endpoint?(token, integration) || - valid_for_managed?(token) + valid_for_cluster?(token) end def valid_for_manual?(token) @@ -83,18 +83,20 @@ module Projects compare_token(token, integration.token) end - def valid_for_managed?(token) - prometheus_application = available_prometheus_application(project) - return false unless prometheus_application + def valid_for_cluster?(token) + cluster_integration = find_cluster_integration(project) + return false unless cluster_integration + + cluster_integration_token = cluster_integration.alert_manager_token if token - compare_token(token, prometheus_application.alert_manager_token) + compare_token(token, cluster_integration_token) else - prometheus_application.alert_manager_token.nil? + cluster_integration_token.nil? end end - def available_prometheus_application(project) + def find_cluster_integration(project) alert_id = gitlab_alert_id return unless alert_id @@ -105,7 +107,7 @@ module Projects return unless cluster&.enabled? return unless cluster.application_prometheus_available? - cluster.application_prometheus + cluster.application_prometheus || cluster.integration_prometheus end def find_alert(project, metric) diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 8a5e0706126..d9e49dfae61 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -47,16 +47,16 @@ module Projects @old_namespace = project.namespace if Project.where(namespace_id: @new_namespace.try(:id)).where('path = ? or name = ?', project.path, project.name).exists? - raise TransferError.new(s_("TransferProject|Project with same name or path in target namespace already exists")) + raise TransferError, s_("TransferProject|Project with same name or path in target namespace already exists") end if project.has_container_registry_tags? # We currently don't support renaming repository if it contains tags in container registry - raise TransferError.new(s_('TransferProject|Project cannot be transferred, because tags are present in its container registry')) + raise TransferError, s_('TransferProject|Project cannot be transferred, because tags are present in its container registry') end if project.has_packages?(:npm) && !new_namespace_has_same_root?(project) - raise TransferError.new(s_("TransferProject|Root namespace can't be updated if project has NPM packages")) + raise TransferError, s_("TransferProject|Root namespace can't be updated if project has NPM packages") end proceed_to_transfer @@ -170,7 +170,7 @@ module Projects # Move main repository unless move_repo_folder(@old_path, @new_path) - raise TransferError.new(s_("TransferProject|Cannot move project")) + raise TransferError, s_("TransferProject|Cannot move project") end # Disk path is changed; we need to ensure we reload it @@ -223,10 +223,10 @@ module Projects end def update_integrations - project.services.inherit.delete_all - Service.create_from_active_default_integrations(project, :project_id) + project.integrations.inherit.delete_all + Integration.create_from_active_default_integrations(project, :project_id) end end end -Projects::TransferService.prepend_if_ee('EE::Projects::TransferService') +Projects::TransferService.prepend_mod_with('Projects::TransferService') diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index 91632e50ba8..9eccc16a8b2 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -17,7 +17,7 @@ module Projects .from_and_to_forks(@project) merge_requests.find_each do |mr| - ::MergeRequests::CloseService.new(@project, @current_user).execute(mr) + ::MergeRequests::CloseService.new(project: @project, current_user: @current_user).execute(mr) log_info(message: "UnlinkForkService: Closed merge request", merge_request_id: mr.id) end diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index b63903c6c61..4272e1dc8b6 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -102,7 +102,7 @@ module Projects File.open(file, 'r') do |f| f.read end - rescue + rescue StandardError nil end end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 6fa42b293c5..8ea35131339 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -48,7 +48,7 @@ module Projects end rescue InvalidStateError => e error(e.message) - rescue => e + rescue StandardError => e error(e.message) raise e end @@ -145,7 +145,7 @@ module Projects FileUtils.mkdir_p(pages_path) begin FileUtils.move(public_path, previous_public_path) - rescue + rescue StandardError end FileUtils.move(archive_public_path, public_path) ensure @@ -267,4 +267,4 @@ module Projects end end -Projects::UpdatePagesService.prepend_if_ee('EE::Projects::UpdatePagesService') +Projects::UpdatePagesService.prepend_mod_with('Projects::UpdatePagesService') diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index 8832a1bc027..9f4f6133d92 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -24,7 +24,7 @@ module Projects hard_retry_or_fail(remote_mirror, e.message, tries) error(e.message) - rescue => e + rescue StandardError => e remote_mirror.hard_fail!(e.message) raise e end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 8384bfa813f..541b333aae3 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -49,11 +49,11 @@ module Projects def validate! unless valid_visibility_level_change?(project, params[:visibility_level]) - raise ValidationError.new(s_('UpdateProject|New visibility level not allowed!')) + raise ValidationError, s_('UpdateProject|New visibility level not allowed!') end if renaming_project_with_container_registry_tags? - raise ValidationError.new(s_('UpdateProject|Cannot rename project because it contains container registry tags!')) + raise ValidationError, s_('UpdateProject|Cannot rename project because it contains container registry tags!') end validate_default_branch_change @@ -67,7 +67,7 @@ module Projects if project.change_head(params[:default_branch]) after_default_branch_change(previous_default_branch) else - raise ValidationError.new(s_("UpdateProject|Could not set the default branch")) + raise ValidationError, s_("UpdateProject|Could not set the default branch") end end @@ -170,4 +170,4 @@ module Projects end end -Projects::UpdateService.prepend_if_ee('EE::Projects::UpdateService') +Projects::UpdateService.prepend_mod_with('Projects::UpdateService') diff --git a/app/services/projects/update_statistics_service.rb b/app/services/projects/update_statistics_service.rb index a0793cff2df..71f5a8e633d 100644 --- a/app/services/projects/update_statistics_service.rb +++ b/app/services/projects/update_statistics_service.rb @@ -2,18 +2,49 @@ module Projects class UpdateStatisticsService < BaseService + include ::Gitlab::Utils::StrongMemoize + + STAT_TO_CACHED_METHOD = { + repository_size: :size, + commit_count: :commit_count + }.freeze + def execute return unless project Gitlab::AppLogger.info("Updating statistics for project #{project.id}") - project.statistics.refresh!(only: statistics.map(&:to_sym)) + expire_repository_caches + expire_wiki_caches + project.statistics.refresh!(only: statistics) end private + def expire_repository_caches + if statistics.empty? + project.repository.expire_statistics_caches + elsif method_caches_to_expire.present? + project.repository.expire_method_caches(method_caches_to_expire) + end + end + + def expire_wiki_caches + return unless project.wiki_enabled? && statistics.include?(:wiki_size) + + project.wiki.repository.expire_method_caches([:size]) + end + + def method_caches_to_expire + strong_memoize(:method_caches_to_expire) do + statistics.map { |stat| STAT_TO_CACHED_METHOD[stat] }.compact + end + end + def statistics - params[:statistics] + strong_memoize(:statistics) do + params[:statistics]&.map(&:to_sym) + end end end end diff --git a/app/services/prometheus/create_default_alerts_service.rb b/app/services/prometheus/create_default_alerts_service.rb index 4ae2743cc28..e59b0a8e8e3 100644 --- a/app/services/prometheus/create_default_alerts_service.rb +++ b/app/services/prometheus/create_default_alerts_service.rb @@ -84,7 +84,7 @@ module Prometheus def environment strong_memoize(:environment) do - EnvironmentsFinder.new(project, nil, name: 'production').execute.first || + Environments::EnvironmentsFinder.new(project, nil, name: 'production').execute.first || project.environments.first end end diff --git a/app/services/protected_branches/access_level_params.rb b/app/services/protected_branches/access_level_params.rb index e34bc23b4dc..6f7a289d9b4 100644 --- a/app/services/protected_branches/access_level_params.rb +++ b/app/services/protected_branches/access_level_params.rb @@ -34,4 +34,4 @@ module ProtectedBranches end end -ProtectedBranches::AccessLevelParams.prepend_if_ee('EE::ProtectedBranches::AccessLevelParams') +ProtectedBranches::AccessLevelParams.prepend_mod_with('ProtectedBranches::AccessLevelParams') diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb index bf1a966472b..3e5122a1523 100644 --- a/app/services/protected_branches/api_service.rb +++ b/app/services/protected_branches/api_service.rb @@ -21,4 +21,4 @@ module ProtectedBranches end end -ProtectedBranches::ApiService.prepend_if_ee('EE::ProtectedBranches::ApiService') +ProtectedBranches::ApiService.prepend_mod_with('ProtectedBranches::ApiService') diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb index 3c86d7d087d..37083a4a9e4 100644 --- a/app/services/protected_branches/create_service.rb +++ b/app/services/protected_branches/create_service.rb @@ -26,4 +26,4 @@ module ProtectedBranches end end -ProtectedBranches::CreateService.prepend_if_ee('EE::ProtectedBranches::CreateService') +ProtectedBranches::CreateService.prepend_mod_with('ProtectedBranches::CreateService') diff --git a/app/services/protected_branches/destroy_service.rb b/app/services/protected_branches/destroy_service.rb index acd15b0214f..dc177f0ac09 100644 --- a/app/services/protected_branches/destroy_service.rb +++ b/app/services/protected_branches/destroy_service.rb @@ -10,4 +10,4 @@ module ProtectedBranches end end -ProtectedBranches::DestroyService.prepend_if_ee('EE::ProtectedBranches::DestroyService') +ProtectedBranches::DestroyService.prepend_mod_with('ProtectedBranches::DestroyService') diff --git a/app/services/protected_branches/legacy_api_update_service.rb b/app/services/protected_branches/legacy_api_update_service.rb index 0cad23f20f7..8ff6c4bd734 100644 --- a/app/services/protected_branches/legacy_api_update_service.rb +++ b/app/services/protected_branches/legacy_api_update_service.rb @@ -49,4 +49,4 @@ module ProtectedBranches end end -ProtectedBranches::LegacyApiUpdateService.prepend_if_ee('EE::ProtectedBranches::LegacyApiUpdateService') +ProtectedBranches::LegacyApiUpdateService.prepend_mod_with('ProtectedBranches::LegacyApiUpdateService') diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb index 226aefb64d0..1815d92421e 100644 --- a/app/services/protected_branches/update_service.rb +++ b/app/services/protected_branches/update_service.rb @@ -11,4 +11,4 @@ module ProtectedBranches end end -ProtectedBranches::UpdateService.prepend_if_ee('EE::ProtectedBranches::UpdateService') +ProtectedBranches::UpdateService.prepend_mod_with('ProtectedBranches::UpdateService') diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index ea90d8e3dd8..ab489ba49ca 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -190,4 +190,4 @@ module QuickActions end end -QuickActions::InterpretService.prepend_if_ee('EE::QuickActions::InterpretService') +QuickActions::InterpretService.prepend_mod_with('QuickActions::InterpretService') diff --git a/app/services/quick_actions/target_service.rb b/app/services/quick_actions/target_service.rb index a465632ccfb..6eda3c89e6c 100644 --- a/app/services/quick_actions/target_service.rb +++ b/app/services/quick_actions/target_service.rb @@ -37,4 +37,4 @@ module QuickActions end end -QuickActions::TargetService.prepend_if_ee('EE::QuickActions::TargetService') +QuickActions::TargetService.prepend_mod_with('QuickActions::TargetService') diff --git a/app/services/releases/base_service.rb b/app/services/releases/base_service.rb index de7c97b3518..9dd0c9a007a 100644 --- a/app/services/releases/base_service.rb +++ b/app/services/releases/base_service.rb @@ -86,4 +86,4 @@ module Releases end end -Releases::BaseService.prepend_if_ee('EE::Releases::BaseService') +Releases::BaseService.prepend_mod_with('Releases::BaseService') diff --git a/app/services/releases/create_evidence_service.rb b/app/services/releases/create_evidence_service.rb index 78b6d77c2cb..64cce45e400 100644 --- a/app/services/releases/create_evidence_service.rb +++ b/app/services/releases/create_evidence_service.rb @@ -28,4 +28,4 @@ module Releases end end -Releases::CreateEvidenceService.prepend_if_ee('EE::Releases::CreateEvidenceService') +Releases::CreateEvidenceService.prepend_mod_with('Releases::CreateEvidenceService') diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index 11fdbaf3169..1096e207e02 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -57,7 +57,7 @@ module Releases create_evidence!(release, evidence_pipeline) success(tag: tag, release: release) - rescue => e + rescue StandardError => e error(e.message, 400) end diff --git a/app/services/repositories/changelog_service.rb b/app/services/repositories/changelog_service.rb index 0122bfb154d..bac3fdf36da 100644 --- a/app/services/repositories/changelog_service.rb +++ b/app/services/repositories/changelog_service.rb @@ -39,7 +39,7 @@ module Repositories project, user, version:, - branch: project.default_branch_or_master, + branch: project.default_branch_or_main, from: nil, to: branch, date: DateTime.now, diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 620dfff91e2..84f4478f20f 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -120,4 +120,4 @@ module ResourceAccessTokens end end -ResourceAccessTokens::CreateService.prepend_if_ee('EE::ResourceAccessTokens::CreateService') +ResourceAccessTokens::CreateService.prepend_mod_with('ResourceAccessTokens::CreateService') diff --git a/app/services/resource_access_tokens/revoke_service.rb b/app/services/resource_access_tokens/revoke_service.rb index 0924ca3bac4..9543ea4b68d 100644 --- a/app/services/resource_access_tokens/revoke_service.rb +++ b/app/services/resource_access_tokens/revoke_service.rb @@ -67,4 +67,4 @@ module ResourceAccessTokens end end -ResourceAccessTokens::RevokeService.prepend_if_ee('EE::ResourceAccessTokens::RevokeService') +ResourceAccessTokens::RevokeService.prepend_mod_with('ResourceAccessTokens::RevokeService') diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb index 89eb90e9360..3797d41a5df 100644 --- a/app/services/resource_events/change_labels_service.rb +++ b/app/services/resource_events/change_labels_service.rb @@ -44,4 +44,4 @@ module ResourceEvents end end -ResourceEvents::ChangeLabelsService.prepend_if_ee('EE::ResourceEvents::ChangeLabelsService') +ResourceEvents::ChangeLabelsService.prepend_mod_with('ResourceEvents::ChangeLabelsService') diff --git a/app/services/resource_events/merge_into_notes_service.rb b/app/services/resource_events/merge_into_notes_service.rb index 122bcb8550f..ea465c1e75e 100644 --- a/app/services/resource_events/merge_into_notes_service.rb +++ b/app/services/resource_events/merge_into_notes_service.rb @@ -37,4 +37,4 @@ module ResourceEvents end end -ResourceEvents::MergeIntoNotesService.prepend_if_ee('EE::ResourceEvents::MergeIntoNotesService') +ResourceEvents::MergeIntoNotesService.prepend_mod_with('ResourceEvents::MergeIntoNotesService') diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index 055034d87a1..661aafc70cd 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -38,4 +38,4 @@ module Search end end -Search::GlobalService.prepend_if_ee('EE::Search::GlobalService') +Search::GlobalService.prepend_mod_with('Search::GlobalService') diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb index 4b2d8499582..daed0df83f3 100644 --- a/app/services/search/group_service.rb +++ b/app/services/search/group_service.rb @@ -31,4 +31,4 @@ module Search end end -Search::GroupService.prepend_if_ee('EE::Search::GroupService') +Search::GroupService.prepend_mod_with('Search::GroupService') diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index 4227dfe2fac..3181c0098cc 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -37,4 +37,4 @@ module Search end end -Search::ProjectService.prepend_if_ee('EE::Search::ProjectService') +Search::ProjectService.prepend_mod_with('Search::ProjectService') diff --git a/app/services/search/snippet_service.rb b/app/services/search/snippet_service.rb index 30401b28571..b629fd305d7 100644 --- a/app/services/search/snippet_service.rb +++ b/app/services/search/snippet_service.rb @@ -12,4 +12,4 @@ module Search end end -Search::SnippetService.prepend_if_ee('::EE::Search::SnippetService') +Search::SnippetService.prepend_mod_with('Search::SnippetService') diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 84d7e33c3d0..389cf17e115 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -140,4 +140,4 @@ class SearchService attr_reader :current_user, :params end -SearchService.prepend_if_ee('EE::SearchService') +SearchService.prepend_mod_with('SearchService') diff --git a/app/services/security/ci_configuration/base_create_service.rb b/app/services/security/ci_configuration/base_create_service.rb new file mode 100644 index 00000000000..adb45244adb --- /dev/null +++ b/app/services/security/ci_configuration/base_create_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Security + module CiConfiguration + class BaseCreateService + attr_reader :branch_name, :current_user, :project + + def initialize(project, current_user) + @project = project + @current_user = current_user + @branch_name = project.repository.next_branch(next_branch) + end + + def execute + project.repository.add_branch(current_user, branch_name, project.default_branch) + + attributes_for_commit = attributes + + result = ::Files::MultiService.new(project, current_user, attributes_for_commit).execute + + return ServiceResponse.error(message: result[:message]) unless result[:status] == :success + + track_event(attributes_for_commit) + ServiceResponse.success(payload: { branch: branch_name, success_path: successful_change_path }) + rescue Gitlab::Git::PreReceiveError => e + ServiceResponse.error(message: e.message) + rescue StandardError + project.repository.rm_branch(current_user, branch_name) if project.repository.branch_exists?(branch_name) + raise + end + + private + + def attributes + { + commit_message: message, + branch_name: branch_name, + start_branch: branch_name, + actions: [action] + } + end + + def existing_gitlab_ci_content + @gitlab_ci_yml ||= project.repository.gitlab_ci_yml_for(project.repository.root_ref_sha) + YAML.safe_load(@gitlab_ci_yml) if @gitlab_ci_yml + end + + def successful_change_path + merge_request_params = { source_branch: branch_name, description: description } + Gitlab::Routing.url_helpers.project_new_merge_request_url(project, merge_request: merge_request_params) + end + + def track_event(attributes_for_commit) + action = attributes_for_commit[:actions].first + + Gitlab::Tracking.event( + self.class.to_s, action[:action], label: action[:default_values_overwritten].to_s + ) + end + end + end +end diff --git a/app/services/security/ci_configuration/sast_create_service.rb b/app/services/security/ci_configuration/sast_create_service.rb index 8fc3b8d078c..f495cac18f8 100644 --- a/app/services/security/ci_configuration/sast_create_service.rb +++ b/app/services/security/ci_configuration/sast_create_service.rb @@ -2,64 +2,30 @@ module Security module CiConfiguration - class SastCreateService < ::BaseService + class SastCreateService < ::Security::CiConfiguration::BaseCreateService + attr_reader :params + def initialize(project, current_user, params) - @project = project - @current_user = current_user + super(project, current_user) @params = params - @branch_name = @project.repository.next_branch('set-sast-config') - end - - def execute - attributes_for_commit = attributes - result = ::Files::MultiService.new(@project, @current_user, attributes_for_commit).execute - - if result[:status] == :success - result[:success_path] = successful_change_path - track_event(attributes_for_commit) - else - result[:errors] = result[:message] - end - - result - - rescue Gitlab::Git::PreReceiveError => e - { status: :error, errors: e.message } end private - def attributes - actions = Security::CiConfiguration::SastBuildActions.new(@project.auto_devops_enabled?, @params, existing_gitlab_ci_content).generate - - @project.repository.add_branch(@current_user, @branch_name, @project.default_branch) - message = _('Set .gitlab-ci.yml to enable or configure SAST') - - { - commit_message: message, - branch_name: @branch_name, - start_branch: @branch_name, - actions: actions - } + def action + Security::CiConfiguration::SastBuildAction.new(project.auto_devops_enabled?, params, existing_gitlab_ci_content).generate end - def existing_gitlab_ci_content - gitlab_ci_yml = @project.repository.gitlab_ci_yml_for(@project.repository.root_ref_sha) - YAML.safe_load(gitlab_ci_yml) if gitlab_ci_yml + def next_branch + 'set-sast-config' end - def successful_change_path - description = _('Set .gitlab-ci.yml to enable or configure SAST security scanning using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings) to customize SAST settings.') - merge_request_params = { source_branch: @branch_name, description: description } - Gitlab::Routing.url_helpers.project_new_merge_request_url(@project, merge_request: merge_request_params) + def message + _('Configure SAST in `.gitlab-ci.yml`, creating this file if it does not already exist') end - def track_event(attributes_for_commit) - action = attributes_for_commit[:actions].first - - Gitlab::Tracking.event( - self.class.to_s, action[:action], label: action[:default_values_overwritten].to_s - ) + def description + _('Configure SAST in `.gitlab-ci.yml` using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/sast/#customizing-the-sast-settings) to customize SAST settings.') end end end diff --git a/app/services/security/ci_configuration/sast_parser_service.rb b/app/services/security/ci_configuration/sast_parser_service.rb index a8fe5764d19..5220525d552 100644 --- a/app/services/security/ci_configuration/sast_parser_service.rb +++ b/app/services/security/ci_configuration/sast_parser_service.rb @@ -74,7 +74,7 @@ module Security def sast_excluded_analyzers strong_memoize(:sast_excluded_analyzers) do - all_analyzers = Security::CiConfiguration::SastBuildActions::SAST_DEFAULT_ANALYZERS.split(', ') rescue [] + all_analyzers = Security::CiConfiguration::SastBuildAction::SAST_DEFAULT_ANALYZERS.split(', ') rescue [] enabled_analyzers = sast_default_analyzers.split(',').map(&:strip) rescue [] excluded_analyzers = gitlab_ci_yml_attributes["SAST_EXCLUDED_ANALYZERS"] || sast_template_attributes["SAST_EXCLUDED_ANALYZERS"] diff --git a/app/services/security/ci_configuration/secret_detection_create_service.rb b/app/services/security/ci_configuration/secret_detection_create_service.rb new file mode 100644 index 00000000000..ff3458d36fc --- /dev/null +++ b/app/services/security/ci_configuration/secret_detection_create_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Security + module CiConfiguration + class SecretDetectionCreateService < ::Security::CiConfiguration::BaseCreateService + private + + def action + Security::CiConfiguration::SecretDetectionBuildAction.new(project.auto_devops_enabled?, existing_gitlab_ci_content).generate + end + + def next_branch + 'set-secret-detection-config' + end + + def message + _('Configure Secret Detection in `.gitlab-ci.yml`, creating this file if it does not already exist') + end + + def description + _('Configure Secret Detection in `.gitlab-ci.yml` using the GitLab managed template. You can [add variable overrides](https://docs.gitlab.com/ee/user/application_security/secret_detection/#customizing-settings) to customize Secret Detection settings.') + end + end + end +end diff --git a/app/services/service_response.rb b/app/services/service_response.rb index 74c0be22d46..6bc394d2ae2 100644 --- a/app/services/service_response.rb +++ b/app/services/service_response.rb @@ -18,6 +18,14 @@ class ServiceResponse self.http_status = http_status end + def [](key) + to_h[key] + end + + def to_h + (payload || {}).merge(status: status, message: message, http_status: http_status) + end + def success? status == :success end diff --git a/app/services/snippets/bulk_destroy_service.rb b/app/services/snippets/bulk_destroy_service.rb index a612d8f8dfc..430e8330b59 100644 --- a/app/services/snippets/bulk_destroy_service.rb +++ b/app/services/snippets/bulk_destroy_service.rb @@ -27,7 +27,7 @@ module Snippets rescue DeleteRepositoryError attempt_rollback_repositories service_response_error('Failed to delete snippet repositories.', 400) - rescue + rescue StandardError # In case the delete operation fails attempt_rollback_repositories service_response_error('Failed to remove snippets.', 400) diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index c95b459cd2a..aadf9b865b8 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -69,7 +69,7 @@ module Snippets end snippet_saved - rescue => e # Rescuing all because we can receive Creation exceptions, GRPC exceptions, Git exceptions, ... + rescue StandardError => e # Rescuing all because we can receive Creation exceptions, GRPC exceptions, Git exceptions, ... Gitlab::ErrorTracking.log_exception(e, service: 'Snippets::CreateService') # If the commit action failed we need to remove the repository if exists diff --git a/app/services/snippets/destroy_service.rb b/app/services/snippets/destroy_service.rb index f1f80dbaf86..96157434462 100644 --- a/app/services/snippets/destroy_service.rb +++ b/app/services/snippets/destroy_service.rb @@ -30,7 +30,7 @@ module Snippets ServiceResponse.success(message: 'Snippet was deleted.') rescue DestroyError service_response_error('Failed to remove snippet repository.', 400) - rescue + rescue StandardError attempt_rollback_repository service_response_error('Failed to remove snippet.', 400) end @@ -59,4 +59,4 @@ module Snippets end end -Snippets::DestroyService.prepend_if_ee('EE::Snippets::DestroyService') +Snippets::DestroyService.prepend_mod_with('Snippets::DestroyService') diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index aedb6a4819d..4088a08272d 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -78,7 +78,7 @@ module Snippets create_commit(snippet) true - rescue => e + rescue StandardError => e # Restore old attributes but re-assign changes so they're not lost unless snippet.previous_changes.empty? snippet.previous_changes.each { |attr, value| snippet[attr] = value[0] } diff --git a/app/services/spam/akismet_service.rb b/app/services/spam/akismet_service.rb index e11a1dbdd96..4e56972ccd5 100644 --- a/app/services/spam/akismet_service.rb +++ b/app/services/spam/akismet_service.rb @@ -20,13 +20,13 @@ module Spam created_at: DateTime.current, author: owner_name, author_email: owner_email, - referrer: options[:referrer] + referer: options[:referer] } begin is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params) is_spam || is_blatant - rescue => e + rescue StandardError => e Gitlab::AppLogger.error("Unable to connect to Akismet: #{e}, skipping check") false end @@ -66,7 +66,7 @@ module Spam begin akismet_client.public_send(type, options[:ip_address], options[:user_agent], params) # rubocop:disable GitlabSecurity/PublicSend true - rescue => e + rescue StandardError => e Gitlab::AppLogger.error("Unable to connect to Akismet: #{e}, skipping!") false end diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb index 2220198583c..3ae5111b994 100644 --- a/app/services/spam/spam_action_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -53,7 +53,7 @@ module Spam if request options[:ip_address] = request.env['action_dispatch.remote_ip'].to_s options[:user_agent] = request.env['HTTP_USER_AGENT'] - options[:referrer] = request.env['HTTP_REFERRER'] + options[:referer] = request.env['HTTP_REFERER'] else # TODO: This code is never used, because we do not perform a verification if there is not a # request. Why? Should it be deleted? Or should we check even if there is no request? @@ -123,8 +123,16 @@ module Spam # https://gitlab.com/gitlab-org/gitlab/-/issues/214739 target.spam! unless target.allow_possible_spam? create_spam_log(api) + when BLOCK_USER + # TODO: improve BLOCK_USER handling, non-existent until now + # https://gitlab.com/gitlab-org/gitlab/-/issues/329666 + target.spam! unless target.allow_possible_spam? + create_spam_log(api) when ALLOW target.clear_spam_flags! + when NOOP + # spamcheck is not explicitly rendering a verdict & therefore can't make a decision + target.clear_spam_flags! end end end diff --git a/app/services/spam/spam_constants.rb b/app/services/spam/spam_constants.rb index 2a16cfae78b..b654fbbbcc8 100644 --- a/app/services/spam/spam_constants.rb +++ b/app/services/spam/spam_constants.rb @@ -6,6 +6,7 @@ module Spam DISALLOW = "disallow" ALLOW = "allow" BLOCK_USER = "block" + NOOP = "noop" SUPPORTED_VERDICTS = { BLOCK_USER => { @@ -19,6 +20,9 @@ module Spam }, ALLOW => { priority: 4 + }, + NOOP => { + priority: 5 } }.freeze end diff --git a/app/services/spam/spam_verdict_service.rb b/app/services/spam/spam_verdict_service.rb index 7de3bad607a..7155017b73f 100644 --- a/app/services/spam/spam_verdict_service.rb +++ b/app/services/spam/spam_verdict_service.rb @@ -10,25 +10,56 @@ module Spam @request = request @user = user @options = options - @verdict_params = assemble_verdict_params(context) + @context = context end def execute - external_spam_check_result = external_verdict + spamcheck_result = nil + spamcheck_attribs = {} + spamcheck_error = false + + external_spam_check_round_trip_time = Benchmark.realtime do + spamcheck_result, spamcheck_attribs, spamcheck_error = spamcheck_verdict + end + + label = spamcheck_error ? 'ERROR' : spamcheck_result.to_s.upcase + + histogram.observe( { result: label }, external_spam_check_round_trip_time ) + + # assign result to a var for logging it before reassigning to nil when monitorMode is true + original_spamcheck_result = spamcheck_result + + spamcheck_result = nil if spamcheck_attribs&.fetch("monitorMode", "false") == "true" + akismet_result = akismet_verdict # filter out anything we don't recognise, including nils. - valid_results = [external_spam_check_result, akismet_result].compact.select { |r| SUPPORTED_VERDICTS.key?(r) } + valid_results = [spamcheck_result, akismet_result].compact.select { |r| SUPPORTED_VERDICTS.key?(r) } + # Treat nils - such as service unavailable - as ALLOW return ALLOW unless valid_results.any? # Favour the most restrictive result. - valid_results.min_by { |v| SUPPORTED_VERDICTS[v][:priority] } + final_verdict = valid_results.min_by { |v| SUPPORTED_VERDICTS[v][:priority] } + + logger.info(class: self.class.name, + akismet_verdict: akismet_verdict, + spam_check_verdict: original_spamcheck_result, + extra_attributes: spamcheck_attribs, + spam_check_rtt: external_spam_check_round_trip_time.real, + final_verdict: final_verdict, + username: user.username, + user_id: user.id, + target_type: target.class.to_s, + project_id: target.project_id + ) + + final_verdict end private - attr_reader :user, :target, :request, :options, :verdict_params + attr_reader :user, :target, :request, :options, :context def akismet_verdict if akismet.spam? @@ -38,54 +69,41 @@ module Spam end end - def external_verdict + def spamcheck_verdict return unless Gitlab::CurrentSettings.spam_check_endpoint_enabled - return if endpoint_url.blank? begin - result = Gitlab::HTTP.post(endpoint_url, body: verdict_params.to_json, headers: { 'Content-Type' => 'application/json' }) - return unless result - - json_result = Gitlab::Json.parse(result).with_indifferent_access - # @TODO metrics/logging - # Expecting: - # error: (string or nil) - # verdict: (string or nil) - # @TODO log if json_result[:error] - - json_result[:verdict] - rescue *Gitlab::HTTP::HTTP_ERRORS => e - # @TODO: log error via try_post https://gitlab.com/gitlab-org/gitlab/-/issues/219223 + result, attribs, _error = spamcheck_client.issue_spam?(spam_issue: target, user: user, context: context) + return [nil, attribs] unless result + + # @TODO log if error is not nil https://gitlab.com/gitlab-org/gitlab/-/issues/329545 + + return [result, attribs] if result == NOOP || attribs["monitorMode"] == "true" + + # Duplicate logic with Akismet logic in #akismet_verdict + if Gitlab::Recaptcha.enabled? && result != ALLOW + [CONDITIONAL_ALLOW, attribs] + else + [result, attribs] + end + rescue StandardError => e Gitlab::ErrorTracking.log_exception(e) - nil - rescue - # @TODO log - ALLOW + + # Default to ALLOW if any errors occur + [ALLOW, attribs, true] end end - def assemble_verdict_params(context) - return {} unless endpoint_url.present? - - project = target.try(:project) - - context.merge({ - target: { - title: target.spam_title, - description: target.spam_description, - type: target.class.to_s - }, - user: { - created_at: user.created_at, - email: user.email, - username: user.username - }, - user_in_project: user.authorized_project?(project) - }) + def spamcheck_client + @spamcheck_client ||= Gitlab::Spamcheck::Client.new + end + + def logger + @logger ||= Gitlab::AppJsonLogger.build end - def endpoint_url - @endpoint_url ||= Gitlab::CurrentSettings.current_application_settings.spam_check_endpoint_url + def histogram + @histogram ||= Gitlab::Metrics.histogram(:gitlab_spamcheck_request_duration_seconds, 'Request duration to the anti-spam service') end end end diff --git a/app/services/static_site_editor/config_service.rb b/app/services/static_site_editor/config_service.rb index 7b3115468a5..c8e7165e076 100644 --- a/app/services/static_site_editor/config_service.rb +++ b/app/services/static_site_editor/config_service.rb @@ -25,7 +25,7 @@ module StaticSiteEditor ServiceResponse.success(payload: data) rescue ValidationError => e ServiceResponse.error(message: e.message) - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_and_raise_exception(e) end @@ -67,7 +67,7 @@ module StaticSiteEditor def check_for_duplicate_keys!(generated_data, file_data) duplicate_keys = generated_data.keys & file_data.keys - raise ValidationError.new("Duplicate key(s) '#{duplicate_keys}' found.") if duplicate_keys.present? + raise ValidationError, "Duplicate key(s) '#{duplicate_keys}' found." if duplicate_keys.present? end def merged_data(generated_data, file_data) diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb index d628b1ea7c7..4942dd0e913 100644 --- a/app/services/submit_usage_ping_service.rb +++ b/app/services/submit_usage_ping_service.rb @@ -22,7 +22,7 @@ class SubmitUsagePingService usage_data = Gitlab::UsageData.data(force_refresh: true) - raise SubmissionError.new('Usage data is blank') if usage_data.blank? + raise SubmissionError, 'Usage data is blank' if usage_data.blank? raw_usage_data = save_raw_usage_data(usage_data) @@ -33,12 +33,12 @@ class SubmitUsagePingService headers: { 'Content-type' => 'application/json' } ) - raise SubmissionError.new("Unsuccessful response code: #{response.code}") unless response.success? + raise SubmissionError, "Unsuccessful response code: #{response.code}" unless response.success? version_usage_data_id = response.dig('conv_index', 'usage_data_id') || response.dig('dev_ops_score', 'usage_data_id') unless version_usage_data_id.is_a?(Integer) && version_usage_data_id > 0 - raise SubmissionError.new("Invalid usage_data_id in response: #{version_usage_data_id}") + raise SubmissionError, "Invalid usage_data_id in response: #{version_usage_data_id}" end raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id) @@ -73,3 +73,5 @@ class SubmitUsagePingService end end end + +SubmitUsagePingService.prepend_mod diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb index f9783f4271f..6836700a67d 100644 --- a/app/services/suggestions/apply_service.rb +++ b/app/services/suggestions/apply_service.rb @@ -36,12 +36,22 @@ module Suggestions .track_apply_suggestion_action(user: current_user) end + def author + authors = suggestion_set.authors + + return unless authors.one? + + Gitlab::Git::User.from_gitlab(authors.first) + end + def multi_service params = { commit_message: commit_message, branch_name: suggestion_set.branch, start_branch: suggestion_set.branch, - actions: suggestion_set.actions + actions: suggestion_set.actions, + author_name: author&.name, + author_email: author&.email } ::Files::MultiService.new(suggestion_set.project, current_user, params) diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index 53e810035c5..2a2053cb912 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class SystemHooksService - BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember, Group, ProjectMember, User].freeze - def execute_hooks_for(model, event) data = build_event_data(model, event) @@ -12,7 +10,7 @@ class SystemHooksService end def execute_hooks(data, hooks_scope = :all) - SystemHook.hooks_for(hooks_scope).find_each do |hook| + SystemHook.executable.hooks_for(hooks_scope).find_each do |hook| hook.async_execute(data, 'system_hooks') end @@ -22,59 +20,6 @@ class SystemHooksService private def build_event_data(model, event) - # return entire event data from its builder class, if available. - return builder_driven_event_data(model, event) if builder_driven_event_data_available?(model) - - data = { - event_name: build_event_name(model, event), - created_at: model.created_at&.xmlschema, - updated_at: model.updated_at&.xmlschema - } - - case model - when Key - data.merge!( - key: model.key, - id: model.id - ) - - if model.user - data[:username] = model.user.username - end - when Project - data.merge!(project_data(model)) - - if event == :rename || event == :transfer - data[:old_path_with_namespace] = model.old_path_with_namespace - end - end - - data - end - - def build_event_name(model, event) - "#{model.class.name.downcase}_#{event}" - end - - def project_data(model) - owner = model.owner - - { - name: model.name, - path: model.path, - path_with_namespace: model.full_path, - project_id: model.id, - owner_name: owner.name, - owner_email: owner.respond_to?(:email) ? owner.email : "", - project_visibility: model.visibility.downcase - } - end - - def builder_driven_event_data_available?(model) - model.class.in?(BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES) - end - - def builder_driven_event_data(model, event) builder_class = case model when GroupMember Gitlab::HookData::GroupMemberBuilder @@ -84,6 +29,10 @@ class SystemHooksService Gitlab::HookData::ProjectMemberBuilder when User Gitlab::HookData::UserBuilder + when Project + Gitlab::HookData::ProjectBuilder + when Key + Gitlab::HookData::KeyBuilder end builder_class.new(model).build(event) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 4377bd8554b..56a6244eebf 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -338,4 +338,4 @@ module SystemNoteService end end -SystemNoteService.prepend_if_ee('EE::SystemNoteService') +SystemNoteService.prepend_mod_with('SystemNoteService') diff --git a/app/services/system_notes/base_service.rb b/app/services/system_notes/base_service.rb index 7341a25b133..ee7784c127b 100644 --- a/app/services/system_notes/base_service.rb +++ b/app/services/system_notes/base_service.rb @@ -13,10 +13,10 @@ module SystemNotes protected def create_note(note_summary) - note = Note.create(note_summary.note.merge(system: true)) - note.system_note_metadata = SystemNoteMetadata.new(note_summary.metadata) if note_summary.metadata? + note_params = note_summary.note.merge(system: true) + note_params[:system_note_metadata] = SystemNoteMetadata.new(note_summary.metadata) if note_summary.metadata? - note + Note.create(note_params) end def content_tag(*args) diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 60dd56e772a..ae4f65e785c 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -178,8 +178,7 @@ module SystemNotes if noteable.is_a?(ExternalIssue) noteable.project.external_issue_tracker.create_cross_reference_note(noteable, mentioner, author) else - issue_activity_counter.track_issue_cross_referenced_action(author: author) if noteable.is_a?(Issue) - + track_cross_reference_action create_note(NoteSummary.new(noteable, noteable.project, author, body, action: 'cross_reference')) end end @@ -414,7 +413,11 @@ module SystemNotes def issue_activity_counter Gitlab::UsageDataCounters::IssueActivityUniqueCounter end + + def track_cross_reference_action + issue_activity_counter.track_issue_cross_referenced_action(author: author) if noteable.is_a?(Issue) + end end end -SystemNotes::IssuablesService.prepend_if_ee('::EE::SystemNotes::IssuablesService') +SystemNotes::IssuablesService.prepend_mod_with('SystemNotes::IssuablesService') diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb index 650e40680b1..a804a06fe4c 100644 --- a/app/services/system_notes/time_tracking_service.rb +++ b/app/services/system_notes/time_tracking_service.rb @@ -62,12 +62,12 @@ module SystemNotes if time_spent == :reset body = "removed time spent" else - spent_at = noteable.spent_at + spent_at = noteable.spent_at&.to_date parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs) action = time_spent > 0 ? 'added' : 'subtracted' text_parts = ["#{action} #{parsed_time} of time spent"] - text_parts << "at #{spent_at}" if spent_at + text_parts << "at #{spent_at}" if spent_at && spent_at != DateTime.current.to_date body = text_parts.join(' ') end diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb index db47bc024ba..e9a13cee764 100644 --- a/app/services/terraform/remote_state_handler.rb +++ b/app/services/terraform/remote_state_handler.rb @@ -94,7 +94,7 @@ module Terraform end def find_state!(find_params) - find_state(find_params) || raise(ActiveRecord::RecordNotFound.new("Couldn't find state")) + find_state(find_params) || raise(ActiveRecord::RecordNotFound, "Couldn't find state") end end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index e473a6dc594..fc6543a8efc 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -43,11 +43,11 @@ class TodoService # updates the todo counts for those users. # def destroy_target(target) - todo_users = UsersWithPendingTodosFinder.new(target).execute.to_a + todo_user_ids = target.todos.distinct_user_ids yield target - Users::UpdateTodoCountCacheService.new(todo_users).execute if todo_users.present? + Users::UpdateTodoCountCacheService.new(todo_user_ids).execute if todo_user_ids.present? end # When we reassign an assignable object (issuable, alert) we should: @@ -224,7 +224,7 @@ class TodoService return if users.empty? - users_with_pending_todos = pending_todos(users, attributes).pluck_user_id + users_with_pending_todos = pending_todos(users, attributes).distinct_user_ids users.reject! { |user| users_with_pending_todos.include?(user.id) && Feature.disabled?(:multiple_todos, user) } todos = users.map do |user| @@ -234,7 +234,7 @@ class TodoService Todo.create(attributes.merge(user_id: user.id)) end - Users::UpdateTodoCountCacheService.new(users).execute + Users::UpdateTodoCountCacheService.new(users.map(&:id)).execute todos end @@ -371,4 +371,4 @@ class TodoService end end -TodoService.prepend_if_ee('EE::TodoService') +TodoService.prepend_mod_with('TodoService') diff --git a/app/services/todos/destroy/confidential_issue_service.rb b/app/services/todos/destroy/confidential_issue_service.rb index 6cdd8c16894..fadc76b1181 100644 --- a/app/services/todos/destroy/confidential_issue_service.rb +++ b/app/services/todos/destroy/confidential_issue_service.rb @@ -37,7 +37,7 @@ module Todos def todos Todo.joins_issue_and_assignees .where(target: issues) - .where('issues.confidential = ?', true) + .where(issues: { confidential: true }) .where('todos.user_id != issues.author_id') .where('todos.user_id != issue_assignees.user_id') end diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb index 6d4fc3865ac..dfe14225ade 100644 --- a/app/services/todos/destroy/entity_leave_service.rb +++ b/app/services/todos/destroy/entity_leave_service.rb @@ -9,7 +9,7 @@ module Todos def initialize(user_id, entity_id, entity_type) unless %w(Group Project).include?(entity_type) - raise ArgumentError.new("#{entity_type} is not an entity user can leave") + raise ArgumentError, "#{entity_type} is not an entity user can leave" end @user = UserFinder.new(user_id).find_by_id @@ -143,4 +143,4 @@ module Todos end end -Todos::Destroy::EntityLeaveService.prepend_if_ee('EE::Todos::Destroy::EntityLeaveService') +Todos::Destroy::EntityLeaveService.prepend_mod_with('Todos::Destroy::EntityLeaveService') diff --git a/app/services/user_project_access_changed_service.rb b/app/services/user_project_access_changed_service.rb index 11727f05f35..80490bd4c9a 100644 --- a/app/services/user_project_access_changed_service.rb +++ b/app/services/user_project_access_changed_service.rb @@ -26,4 +26,4 @@ class UserProjectAccessChangedService end end -UserProjectAccessChangedService.prepend_if_ee('EE::UserProjectAccessChangedService') +UserProjectAccessChangedService.prepend_mod_with('UserProjectAccessChangedService') diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index 64844a3f002..c89a286cc8b 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -38,4 +38,4 @@ module Users end end -Users::ActivityService.prepend_ee_mod +Users::ActivityService.prepend_mod diff --git a/app/services/users/approve_service.rb b/app/services/users/approve_service.rb index fea7fc55d90..15486ddcd43 100644 --- a/app/services/users/approve_service.rb +++ b/app/services/users/approve_service.rb @@ -47,4 +47,4 @@ module Users end end -Users::ApproveService.prepend_if_ee('EE::Users::ApproveService') +Users::ApproveService.prepend_mod_with('Users::ApproveService') diff --git a/app/services/users/ban_service.rb b/app/services/users/ban_service.rb new file mode 100644 index 00000000000..247ed14966b --- /dev/null +++ b/app/services/users/ban_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Users + class BanService < BaseService + def initialize(current_user) + @current_user = current_user + end + + def execute(user) + if user.ban + log_event(user) + success + else + messages = user.errors.full_messages + error(messages.uniq.join('. ')) + end + end + + private + + def log_event(user) + Gitlab::AppLogger.info(message: "User banned", user: "#{user.username}", email: "#{user.email}", banned_by: "#{current_user.username}", ip_address: "#{current_user.current_sign_in_ip}") + end + end +end diff --git a/app/services/users/block_service.rb b/app/services/users/block_service.rb index 8513664ee85..37921c477b4 100644 --- a/app/services/users/block_service.rb +++ b/app/services/users/block_service.rb @@ -26,4 +26,4 @@ module Users end end -Users::BlockService.prepend_if_ee('EE::Users::BlockService') +Users::BlockService.prepend_mod_with('Users::BlockService') diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb index b3b172f9df2..649cf281ab0 100644 --- a/app/services/users/build_service.rb +++ b/app/services/users/build_service.rb @@ -14,9 +14,11 @@ module Users end def execute(skip_authorization: false) + @skip_authorization = skip_authorization + raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_create_user? - user_params = build_user_params(skip_authorization: skip_authorization) + user_params = build_user_params user = User.new(user_params) if current_user&.admin? @@ -37,6 +39,8 @@ module Users private + attr_reader :skip_authorization + def identity_attributes [:extern_uid, :provider] end @@ -102,7 +106,7 @@ module Users ] end - def build_user_params(skip_authorization:) + def build_user_params if current_user&.admin? user_params = params.slice(*admin_create_params) @@ -111,10 +115,10 @@ module Users end else allowed_signup_params = signup_params - allowed_signup_params << :skip_confirmation if skip_authorization + allowed_signup_params << :skip_confirmation if allow_caller_to_request_skip_confirmation? user_params = params.slice(*allowed_signup_params) - if user_params[:skip_confirmation].nil? + if assign_skip_confirmation_from_settings?(user_params) user_params[:skip_confirmation] = skip_user_confirmation_email_from_setting end @@ -136,6 +140,14 @@ module Users user_params end + def allow_caller_to_request_skip_confirmation? + skip_authorization + end + + def assign_skip_confirmation_from_settings?(user_params) + user_params[:skip_confirmation].nil? + end + def skip_user_confirmation_email_from_setting !Gitlab::CurrentSettings.send_user_confirmation_email end @@ -150,4 +162,4 @@ module Users end end -Users::BuildService.prepend_if_ee('EE::Users::BuildService') +Users::BuildService.prepend_mod_with('Users::BuildService') diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb index ec8b3cea664..757ebd783ee 100644 --- a/app/services/users/create_service.rb +++ b/app/services/users/create_service.rb @@ -26,4 +26,4 @@ module Users end end -Users::CreateService.prepend_if_ee('EE::Users::CreateService') +Users::CreateService.prepend_mod_with('Users::CreateService') diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index 613d2e4ad82..4ec875098fa 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -31,7 +31,7 @@ module Users end if !delete_solo_owned_groups && user.solo_owned_groups.present? - user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user' + user.errors.add(:base, 'You must transfer ownership or delete groups before you can remove user') return user end @@ -73,4 +73,4 @@ module Users end end -Users::DestroyService.prepend_if_ee('EE::Users::DestroyService') +Users::DestroyService.prepend_mod_with('Users::DestroyService') diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb index 1b46edd4d7d..a471f55e644 100644 --- a/app/services/users/migrate_to_ghost_user_service.rb +++ b/app/services/users/migrate_to_ghost_user_service.rb @@ -93,4 +93,4 @@ module Users end end -Users::MigrateToGhostUserService.prepend_if_ee('EE::Users::MigrateToGhostUserService') +Users::MigrateToGhostUserService.prepend_mod_with('Users::MigrateToGhostUserService') diff --git a/app/services/users/registrations_build_service.rb b/app/services/users/registrations_build_service.rb new file mode 100644 index 00000000000..9d7bf0a7e18 --- /dev/null +++ b/app/services/users/registrations_build_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Users + class RegistrationsBuildService < BuildService + extend ::Gitlab::Utils::Override + + private + + override :allow_caller_to_request_skip_confirmation? + def allow_caller_to_request_skip_confirmation? + true + end + + override :assign_skip_confirmation_from_settings? + def assign_skip_confirmation_from_settings?(user_params) + user_params[:skip_confirmation].blank? + end + end +end diff --git a/app/services/users/reject_service.rb b/app/services/users/reject_service.rb index 0e3eb3e5dde..833c30d9427 100644 --- a/app/services/users/reject_service.rb +++ b/app/services/users/reject_service.rb @@ -39,4 +39,4 @@ module Users end end -Users::RejectService.prepend_if_ee('EE::Users::RejectService') +Users::RejectService.prepend_mod_with('Users::RejectService') diff --git a/app/services/users/update_assigned_open_issue_count_service.rb b/app/services/users/update_assigned_open_issue_count_service.rb new file mode 100644 index 00000000000..2ed05853b2f --- /dev/null +++ b/app/services/users/update_assigned_open_issue_count_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Users + # Service class for calculating and caching the number of assigned open issues for a user. + class UpdateAssignedOpenIssueCountService + attr_accessor :target_user + + def initialize(target_user:) + @target_user = target_user + + raise ArgumentError, "Please provide a target user" unless target_user.is_a?(User) + end + + def execute + value = calculate_count + Rails.cache.write(cache_key, value, expires_in: User::COUNT_CACHE_VALIDITY_PERIOD) + + ServiceResponse.success(payload: { count: value }) + rescue StandardError => e + ServiceResponse.error(message: e.message) + end + + private + + def cache_key + ['users', target_user.id, 'assigned_open_issues_count'] + end + + def calculate_count + IssuesFinder.new(target_user, assignee_id: target_user.id, state: 'opened', non_archived: true).execute.count + end + end +end diff --git a/app/services/users/update_canonical_email_service.rb b/app/services/users/update_canonical_email_service.rb index e75452f60fd..c4b7a98f60b 100644 --- a/app/services/users/update_canonical_email_service.rb +++ b/app/services/users/update_canonical_email_service.rb @@ -7,7 +7,7 @@ module Users INCLUDED_DOMAINS_PATTERN = [/gmail.com/].freeze def initialize(user:) - raise ArgumentError.new("Please provide a user") unless user.is_a?(User) + raise ArgumentError, "Please provide a user" unless user.is_a?(User) @user = user end diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index b69720eefd6..ff08c806319 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -34,7 +34,7 @@ module Users def execute!(*args, &block) result = execute(*args, &block) - raise ActiveRecord::RecordInvalid.new(@user) unless result[:status] == :success + raise ActiveRecord::RecordInvalid, @user unless result[:status] == :success true end @@ -96,4 +96,4 @@ module Users end end -Users::UpdateService.prepend_if_ee('EE::Users::UpdateService') +Users::UpdateService.prepend_mod_with('Users::UpdateService') diff --git a/app/services/users/update_todo_count_cache_service.rb b/app/services/users/update_todo_count_cache_service.rb index 03ab66bd64a..3407b22e355 100644 --- a/app/services/users/update_todo_count_cache_service.rb +++ b/app/services/users/update_todo_count_cache_service.rb @@ -4,31 +4,34 @@ module Users class UpdateTodoCountCacheService < BaseService QUERY_BATCH_SIZE = 10 - attr_reader :users + attr_reader :user_ids - # users - An array of User objects - def initialize(users) - @users = users + # user_ids - An array of User IDs + def initialize(user_ids) + @user_ids = user_ids end def execute - users.each_slice(QUERY_BATCH_SIZE) do |users_batch| - todo_counts = Todo.for_user(users_batch).count_grouped_by_user_id_and_state + user_ids.each_slice(QUERY_BATCH_SIZE) do |user_ids_batch| + todo_counts = Todo.for_user(user_ids_batch).count_grouped_by_user_id_and_state - users_batch.each do |user| - update_count_cache(user, todo_counts, :done) - update_count_cache(user, todo_counts, :pending) + user_ids_batch.each do |user_id| + update_count_cache(user_id, todo_counts, :done) + update_count_cache(user_id, todo_counts, :pending) end end end private - def update_count_cache(user, todo_counts, state) - count = todo_counts.fetch([user.id, state.to_s], 0) - expiration_time = user.count_cache_validity_period + def update_count_cache(user_id, todo_counts, state) + count = todo_counts.fetch([user_id, state.to_s], 0) - Rails.cache.write(['users', user.id, "todos_#{state}_count"], count, expires_in: expiration_time) + Rails.cache.write( + ['users', user_id, "todos_#{state}_count"], + count, + expires_in: User::COUNT_CACHE_VALIDITY_PERIOD + ) end end end diff --git a/app/services/users/upsert_credit_card_validation_service.rb b/app/services/users/upsert_credit_card_validation_service.rb new file mode 100644 index 00000000000..70a96b3ec6b --- /dev/null +++ b/app/services/users/upsert_credit_card_validation_service.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Users + class UpsertCreditCardValidationService < BaseService + def initialize(params) + @params = params.to_h.with_indifferent_access + end + + def execute + ::Users::CreditCardValidation.upsert(@params) + + ServiceResponse.success(message: 'CreditCardValidation was set') + rescue ActiveRecord::InvalidForeignKey, ActiveRecord::NotNullViolation => e + ServiceResponse.error(message: "Could not set CreditCardValidation: #{e.message}") + rescue StandardError => e + Gitlab::ErrorTracking.track_exception(e, params: @params, class: self.class.to_s) + ServiceResponse.error(message: "Could not set CreditCardValidation: #{e.message}") + end + end +end diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb index a9e219547d7..eab1e91dc89 100644 --- a/app/services/verify_pages_domain_service.rb +++ b/app/services/verify_pages_domain_service.rb @@ -90,7 +90,7 @@ class VerifyPagesDomainService < BaseService records.any? do |record| record == domain.keyed_verification_code || record == domain.verification_code end - rescue => err + rescue StandardError => err log_error("Failed to check TXT records on #{domain_name} for #{domain.domain}: #{err}") false end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 5a51b42f9f9..654d9356739 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -6,6 +6,18 @@ class WebHookService attr_reader :body, :headers, :code + def success? + false + end + + def redirection? + false + end + + def internal_server_error? + true + end + def initialize @headers = Gitlab::HTTP::Response::Headers.new({}) @body = '' @@ -15,6 +27,7 @@ class WebHookService REQUEST_BODY_SIZE_LIMIT = 25.megabytes GITLAB_EVENT_HEADER = 'X-Gitlab-Event' + MAX_FAILURES = 100 attr_accessor :hook, :data, :hook_name, :request_options @@ -33,6 +46,8 @@ class WebHookService end def execute + return { status: :error, message: 'Hook disabled' } unless hook.executable? + start_time = Gitlab::Metrics::System.monotonic_time response = if parsed_url.userinfo.blank? @@ -76,7 +91,11 @@ class WebHookService end def async_execute - WebHookWorker.perform_async(hook.id, data, hook_name) + if rate_limited?(hook) + log_rate_limit(hook) + else + WebHookWorker.perform_async(hook.id, data, hook_name) + end end private @@ -104,6 +123,8 @@ class WebHookService end def log_execution(trigger:, url:, request_data:, response:, execution_duration:, error_message: nil) + handle_failure(response, hook) + WebHookLog.create( web_hook: hook, trigger: trigger, @@ -118,6 +139,17 @@ class WebHookService ) end + def handle_failure(response, hook) + if response.success? || response.redirection? + hook.enable! + elsif response.internal_server_error? + next_backoff = hook.next_backoff + hook.update!(disabled_until: next_backoff.from_now, backoff_count: hook.backoff_count + 1) + else + hook.update!(recent_failures: hook.recent_failures + 1) if hook.recent_failures < MAX_FAILURES + end + end + def build_headers(hook_name) @headers ||= begin { @@ -142,4 +174,34 @@ class WebHookService response.body.encode('UTF-8', invalid: :replace, undef: :replace, replace: '') end + + def rate_limited?(hook) + return false unless Feature.enabled?(:web_hooks_rate_limit, default_enabled: :yaml) + return false if rate_limit.nil? + + Gitlab::ApplicationRateLimiter.throttled?( + :web_hook_calls, + scope: [hook], + threshold: rate_limit + ) + end + + def rate_limit + @rate_limit ||= hook.rate_limit + end + + def log_rate_limit(hook) + payload = { + message: 'Webhook rate limit exceeded', + hook_id: hook.id, + hook_type: hook.type, + hook_name: hook_name + } + + Gitlab::AuthLogger.error(payload) + + # Also log into application log for now, so we can use this information + # to determine suitable limits for gitlab.com + Gitlab::AppLogger.error(payload) + end end diff --git a/app/services/wiki_pages/base_service.rb b/app/services/wiki_pages/base_service.rb index fd234630633..4ec884469eb 100644 --- a/app/services/wiki_pages/base_service.rb +++ b/app/services/wiki_pages/base_service.rb @@ -61,4 +61,4 @@ module WikiPages end end -WikiPages::BaseService.prepend_if_ee('EE::WikiPages::BaseService') +WikiPages::BaseService.prepend_mod_with('WikiPages::BaseService') diff --git a/app/services/wiki_pages/update_service.rb b/app/services/wiki_pages/update_service.rb index f2fc6b37c34..88275f8c417 100644 --- a/app/services/wiki_pages/update_service.rb +++ b/app/services/wiki_pages/update_service.rb @@ -2,6 +2,8 @@ module WikiPages class UpdateService < WikiPages::BaseService + UpdateError = Class.new(StandardError) + def execute(page) # this class is not thread safe! @old_slug = page.slug @@ -10,11 +12,15 @@ module WikiPages execute_hooks(page) ServiceResponse.success(payload: { page: page }) else - ServiceResponse.error( - message: _('Could not update wiki page'), - payload: { page: page } - ) + raise UpdateError, s_('Could not update wiki page') end + rescue UpdateError, WikiPage::PageChangedError, WikiPage::PageRenameError => e + page.update_attributes(@params) # rubocop:disable Rails/ActiveRecordAliases + + ServiceResponse.error( + message: e.message, + payload: { page: page } + ) end def usage_counter_action diff --git a/app/uploaders/bulk_imports/export_uploader.rb b/app/uploaders/bulk_imports/export_uploader.rb new file mode 100644 index 00000000000..356e5ce028e --- /dev/null +++ b/app/uploaders/bulk_imports/export_uploader.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module BulkImports + class ExportUploader < ImportExportUploader + EXTENSION_WHITELIST = %w[ndjson.gz].freeze + end +end diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb index 887cb702acf..95bc2680ed6 100644 --- a/app/uploaders/file_mover.rb +++ b/app/uploaders/file_mover.rb @@ -54,7 +54,7 @@ class FileMover updated_text = to_model.read_attribute(update_field) .gsub(temp_file_uploader.markdown_link, uploader.markdown_link) to_model.update_attribute(update_field, updated_text) - rescue + rescue StandardError revert false end diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index ea71930062c..be5839b7ec5 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -187,7 +187,6 @@ module ObjectStorage hash[:TempPath] = workhorse_local_upload_path end - hash[:FeatureFlagExtractBase] = Feature.enabled?(:workhorse_extract_filename_base, default_enabled: :yaml) hash[:MaximumSize] = maximum_size if maximum_size.present? end end @@ -452,7 +451,7 @@ module ObjectStorage def with_exclusive_lease lease_key = exclusive_lease_key uuid = Gitlab::ExclusiveLease.new(lease_key, timeout: 1.hour.to_i).try_obtain - raise ExclusiveLeaseTaken.new(lease_key) unless uuid + raise ExclusiveLeaseTaken, lease_key unless uuid yield uuid ensure @@ -484,7 +483,7 @@ module ObjectStorage end file - rescue => e + rescue StandardError => e # in case of failure delete new file new_file.delete unless new_file.nil? # revert back to the old file @@ -509,4 +508,4 @@ module ObjectStorage end end -ObjectStorage::Concern.include_if_ee('::EE::ObjectStorage::Concern') +ObjectStorage::Concern.include_mod_with('ObjectStorage::Concern') diff --git a/app/validators/branch_filter_validator.rb b/app/validators/branch_filter_validator.rb index 6a0899be850..89d6343a9a4 100644 --- a/app/validators/branch_filter_validator.rb +++ b/app/validators/branch_filter_validator.rb @@ -20,11 +20,11 @@ class BranchFilterValidator < ActiveModel::EachValidator value_without_wildcards = value.tr('*', 'x') unless Gitlab::GitRefValidator.validate(value_without_wildcards) - record.errors[attribute] << "is not a valid branch name" + record.errors.add(attribute, "is not a valid branch name") end unless value.length <= 4000 - record.errors[attribute] << "is longer than the allowed length of 4000 characters." + record.errors.add(attribute, "is longer than the allowed length of 4000 characters.") end end end diff --git a/app/validators/cron_validator.rb b/app/validators/cron_validator.rb index 6f42bdb5f9b..91b9cfcccc4 100644 --- a/app/validators/cron_validator.rb +++ b/app/validators/cron_validator.rb @@ -10,7 +10,7 @@ class CronValidator < ActiveModel::EachValidator cron_parser = Gitlab::Ci::CronParser.new(record.public_send(attribute), record.cron_timezone) # rubocop:disable GitlabSecurity/PublicSend record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid? else - raise NonWhitelistedAttributeError.new "Non-whitelisted attribute" + raise NonWhitelistedAttributeError, "Non-whitelisted attribute" end end end diff --git a/app/validators/json_schema_validator.rb b/app/validators/json_schema_validator.rb index 8dc6265f471..68f03e8a6a3 100644 --- a/app/validators/json_schema_validator.rb +++ b/app/validators/json_schema_validator.rb @@ -54,4 +54,4 @@ class JsonSchemaValidator < ActiveModel::EachValidator end end -JsonSchemaValidator.prepend_ee_mod +JsonSchemaValidator.prepend_mod diff --git a/app/validators/json_schemas/helm_metadata.json b/app/validators/json_schemas/helm_metadata.json new file mode 100644 index 00000000000..7ac36e956f3 --- /dev/null +++ b/app/validators/json_schemas/helm_metadata.json @@ -0,0 +1,128 @@ +{ + "description": "Helm metadata", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "home": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "type": "string" + } + }, + "version": { + "type": "string" + }, + "description": { + "type": "string" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "maintainers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "icon": { + "type": "string" + }, + "apiVersion": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "tags": { + "type": "string" + }, + "appVersion": { + "type": "string" + }, + "deprecated": { + "type": "boolean" + }, + "annotations": { + "type": "object", + "patternProperties": { + ".+": { + "type": "string" + }, + "additionalProperties": false + } + }, + "kubeVersion": { + "type": "string" + }, + "dependencies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "condition": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "import-values": { + "type": "array", + "items": { + + } + }, + "alias": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + }, + "additionalProperties": false + } + } + }, + "type": { + "type": "string", + "enum": ["application", "library"] + } + }, + "additionalProperties": false, + "required": [ + "name", + "version", + "apiVersion" + ] +}
\ No newline at end of file diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json index dc4880946b2..7c3720dd2e6 100644 --- a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json +++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json @@ -161,6 +161,13 @@ "variables": [] }, { + "name": "semgrep", + "label": "Semgrep", + "enabled": true, + "description": "Multi-language scanning", + "variables": [] + }, + { "name": "sobelow", "label": "Sobelow", "enabled" : true, diff --git a/app/validators/same_project_association_validator.rb b/app/validators/same_project_association_validator.rb index 2af2a21fa9a..2fcc369b6ef 100644 --- a/app/validators/same_project_association_validator.rb +++ b/app/validators/same_project_association_validator.rb @@ -16,6 +16,6 @@ class SameProjectAssociationValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) return if record.project == value&.project - record.errors[attribute] << 'must associate the same project' + record.errors.add(attribute, 'must associate the same project') end end diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml index 1aaea1999e5..872a6bef18b 100644 --- a/app/views/admin/appearances/_form.html.haml +++ b/app/views/admin/appearances/_form.html.haml @@ -1,4 +1,4 @@ -- parsed_with_gfm = "Content parsed with #{link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank')}.".html_safe +- parsed_with_gfm = (_("Content parsed with %{link}.") % { link: link_to('GitLab Flavored Markdown', help_page_path('user/markdown'), target: '_blank') }).html_safe = form_for @appearance, url: admin_appearances_path, html: { class: 'gl-mt-3' } do |f| = form_errors(@appearance) @@ -6,22 +6,22 @@ .row .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 Navigation bar + %h4.gl-mt-0= _('Navigation bar') .col-lg-8 .form-group - = f.label :header_logo, 'Header logo', class: 'col-form-label label-bold pt-0' + = f.label :header_logo, _('Header logo'), class: 'col-form-label label-bold pt-0' %p - if @appearance.header_logo? = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview' - if @appearance.persisted? %br - = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" + = link_to _('Remove header logo'), header_logos_admin_appearances_path, data: { confirm: _("Header logo will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" %hr = f.hidden_field :header_logo_cache = f.file_field :header_logo, class: "", accept: 'image/*' .hint - Maximum file size is 1MB. Pages are optimized for a 28px tall header logo + = _('Maximum file size is 1MB. Pages are optimized for a 28px tall header logo') %hr .row .col-lg-4.profile-settings-sidebar @@ -29,27 +29,27 @@ .col-lg-8 .form-group - = f.label :favicon, 'Favicon', class: 'col-form-label label-bold pt-0' + = f.label :favicon, _('Favicon'), class: 'col-form-label label-bold pt-0' %p - if @appearance.favicon? = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview' - if @appearance.persisted? %br - = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" + = link_to _('Remove favicon'), favicon_admin_appearances_path, data: { confirm: _("Favicon will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm" %hr = f.hidden_field :favicon_cache = f.file_field :favicon, class: '', accept: 'image/*' .hint - Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}. + = _("Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are %{favicon_extension_whitelist}.") % { favicon_extension_whitelist: favicon_extension_whitelist } %br - Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior. + = _("Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.") = render partial: 'admin/appearances/system_header_footer_form', locals: { form: f } %hr .row .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 Sign in/Sign up pages + %h4.gl-mt-0= _('Sign in/Sign up pages') .col-lg-8 .form-group @@ -67,17 +67,17 @@ = image_tag @appearance.logo_path, class: 'appearance-logo-preview' - if @appearance.persisted? %br - = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo" + = link_to _('Remove logo'), logo_admin_appearances_path, data: { confirm: _("Logo will be removed. Are you sure?") }, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo" %hr = f.hidden_field :logo_cache = f.file_field :logo, class: "", accept: 'image/*' .hint - Maximum file size is 1MB. Pages are optimized for a 640x360 px logo. + = _('Maximum file size is 1MB. Pages are optimized for a 640x360 px logo.') %hr .row .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 New project pages + %h4.gl-mt-0= _('New project pages') .col-lg-8 .form-group @@ -90,7 +90,7 @@ %hr .row .col-lg-4.profile-settings-sidebar - %h4.gl-mt-0 Profile image guideline + %h4.gl-mt-0= _('Profile image guideline') .col-lg-8 .form-group @@ -101,13 +101,13 @@ = parsed_with_gfm .gl-mt-3.gl-mb-3 - = f.submit 'Update appearance settings', class: 'btn gl-button btn-confirm' + = f.submit _('Update appearance settings'), class: 'btn gl-button btn-confirm' - if @appearance.persisted? || @appearance.updated_at .mt-4 - if @appearance.persisted? Preview last save: - = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' + = link_to _('Sign-in page'), preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' + = link_to _('New project page'), new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer' - if @appearance.updated_at %span.float-right diff --git a/app/views/admin/appearances/preview_sign_in.html.haml b/app/views/admin/appearances/preview_sign_in.html.haml index f972b3b5cbf..a317611862c 100644 --- a/app/views/admin/appearances/preview_sign_in.html.haml +++ b/app/views/admin/appearances/preview_sign_in.html.haml @@ -1,12 +1,12 @@ -= render 'devise/shared/tab_single', tab_title: 'Sign in preview' += render 'devise/shared/tab_single', tab_title: _('Sign in preview') .login-box %form.gl-show-field-errors .form-group = label_tag :login - = text_field_tag :login, nil, class: "form-control gl-form-input top", title: 'Please provide your username or email address.' + = text_field_tag :login, nil, class: "form-control gl-form-input top", title: _('Please provide your username or email address.') .form-group = label_tag :password - = password_field_tag :password, nil, class: "form-control gl-form-input bottom", title: 'This field is required.' + = password_field_tag :password, nil, class: "form-control gl-form-input bottom", title: _('This field is required.') .form-group - = button_tag "Sign in", class: "btn gl-button btn-confirm" + = button_tag _("Sign in"), class: "btn gl-button btn-confirm" diff --git a/app/views/admin/application_settings/_abuse.html.haml b/app/views/admin/application_settings/_abuse.html.haml index f050c0816b1..fab3ce584f0 100644 --- a/app/views/admin/application_settings/_abuse.html.haml +++ b/app/views/admin/application_settings/_abuse.html.haml @@ -3,9 +3,9 @@ %fieldset .form-group - = f.label :abuse_notification_email, 'Abuse reports notification email', class: 'label-bold' + = f.label :abuse_notification_email, _('Abuse reports notification email'), class: 'label-bold' = f.text_field :abuse_notification_email, class: 'form-control gl-form-input' .form-text.text-muted - Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area. + = _('Abuse reports will be sent to this address if it is set. Abuse reports are always available in the admin area.') - = f.submit 'Save changes', class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_floc.html.haml b/app/views/admin/application_settings/_floc.html.haml new file mode 100644 index 00000000000..398064f9730 --- /dev/null +++ b/app/views/admin/application_settings/_floc.html.haml @@ -0,0 +1,22 @@ +- expanded = integration_expanded?('floc_') + +%section.settings.no-animate#js-floc-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = s_('FloC|Federated Learning of Cohorts') + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = s_('FloC|Configure whether you want to participate in FloC.').html_safe + = link_to sprite_icon('question-o'), 'https://github.com/WICG/floc', target: '_blank', class: 'has-tooltip', title: _('More information') + + .settings-content + = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-floc-settings'), html: { class: 'fieldset-form', id: 'floc-settings' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .form-check + = f.check_box :floc_enabled, class: 'form-check-input' + = f.label :floc_enabled, s_('FloC|Enable FloC (Federated Learning of Cohorts)'), class: 'form-check-label' + = f.submit s_('Save changes'), class: 'gl-button btn btn-confirm' diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml index 72e7cb0b437..b28a53d8bf6 100644 --- a/app/views/admin/application_settings/_gitaly.html.haml +++ b/app/views/admin/application_settings/_gitaly.html.haml @@ -3,7 +3,7 @@ %fieldset .form-group - = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'label-bold' + = f.label :gitaly_timeout_default, _('Default Timeout Period'), class: 'label-bold' = f.number_field :gitaly_timeout_default, class: 'form-control gl-form-input' .form-text.text-muted Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced @@ -12,16 +12,16 @@ worker timeout, the remaining time from the worker timeout would be used to avoid having to terminate the worker. .form-group - = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'label-bold' + = f.label :gitaly_timeout_fast, _('Fast Timeout Period'), class: 'label-bold' = f.number_field :gitaly_timeout_fast, class: 'form-control gl-form-input' .form-text.text-muted Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast. If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' can help maintain the stability of the GitLab instance. .form-group - = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'label-bold' + = f.label :gitaly_timeout_medium, _('Medium Timeout Period'), class: 'label-bold' = f.number_field :gitaly_timeout_medium, class: 'form-control gl-form-input' .form-text.text-muted Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout. - = f.submit 'Save changes', class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_initial_branch_name.html.haml b/app/views/admin/application_settings/_initial_branch_name.html.haml index b5c178641df..f881808e51f 100644 --- a/app/views/admin/application_settings/_initial_branch_name.html.haml +++ b/app/views/admin/application_settings/_initial_branch_name.html.haml @@ -1,12 +1,12 @@ = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) - - fallback_branch_name = '<code>master</code>' + - fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value}</code>" %fieldset .form-group = f.label :default_branch_name, _('Default initial branch name'), class: 'label-light' - = f.text_field :default_branch_name, placeholder: 'master', class: 'form-control gl-form-input' + = f.text_field :default_branch_name, placeholder: Gitlab::DefaultBranch.value, class: 'form-control gl-form-input' %span.form-text.text-muted = (_("Changes affect new repositories only. If not specified, Git's default name %{branch_name_default} will be used.") % { branch_name_default: fallback_branch_name } ).html_safe diff --git a/app/views/admin/application_settings/_package_registry.html.haml b/app/views/admin/application_settings/_package_registry.html.haml index 0ca8493c596..8de65f267d2 100644 --- a/app/views/admin/application_settings/_package_registry.html.haml +++ b/app/views/admin/application_settings/_package_registry.html.haml @@ -45,6 +45,9 @@ = f.label :pypi_max_file_size, _('Maximum PyPI package file size in bytes'), class: 'label-bold' = f.number_field :pypi_max_file_size, class: 'form-control gl-form-input' .form-group + = f.label :terraform_module_max_file_size, _('Maximum Terraform Module package file size in bytes'), class: 'label-bold' + = f.number_field :terraform_module_max_file_size, class: 'form-control gl-form-input' + .form-group = f.label :generic_packages_max_file_size, _('Generic package file size in bytes'), class: 'label-bold' = f.number_field :generic_packages_max_file_size, class: 'form-control gl-form-input' = f.submit _('Save %{name} size limits').html_safe % { name: plan.name.capitalize }, class: 'btn gl-button btn-confirm' diff --git a/app/views/admin/application_settings/_package_registry_limits.html.haml b/app/views/admin/application_settings/_package_registry_limits.html.haml new file mode 100644 index 00000000000..b1dfd04c55e --- /dev/null +++ b/app/views/admin/application_settings/_package_registry_limits.html.haml @@ -0,0 +1,37 @@ += form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-packages-limits-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + %h5 + = _('Unauthenticated API request rate limit') + .form-group + .form-check + = f.check_box :throttle_unauthenticated_packages_api_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_unauthenticated_packages_api_checkbox' } + = f.label :throttle_unauthenticated_packages_api_enabled, class: 'form-check-label label-bold' do + = _('Enable unauthenticated API request rate limit') + %span.form-text.text-muted + = _('Helps reduce request volume (e.g. from crawlers or abusive bots)') + .form-group + = f.label :throttle_unauthenticated_packages_api_requests_per_period, 'Max unauthenticated API requests per period per IP', class: 'label-bold' + = f.number_field :throttle_unauthenticated_packages_api_requests_per_period, class: 'form-control gl-form-input' + .form-group + = f.label :throttle_unauthenticated_packages_api_period_in_seconds, 'Unauthenticated API rate limit period in seconds', class: 'label-bold' + = f.number_field :throttle_unauthenticated_packages_api_period_in_seconds, class: 'form-control gl-form-input' + %hr + %h5 + = _('Authenticated API request rate limit') + .form-group + .form-check + = f.check_box :throttle_authenticated_packages_api_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_packages_api_checkbox' } + = f.label :throttle_authenticated_packages_api_enabled, class: 'form-check-label label-bold' do + = _('Enable authenticated API request rate limit') + %span.form-text.text-muted + = _('Helps reduce request volume (e.g. from crawlers or abusive bots)') + .form-group + = f.label :throttle_authenticated_packages_api_requests_per_period, 'Max authenticated API requests per period per user', class: 'label-bold' + = f.number_field :throttle_authenticated_packages_api_requests_per_period, class: 'form-control gl-form-input' + .form-group + = f.label :throttle_authenticated_packages_api_period_in_seconds, 'Authenticated API rate limit period in seconds', class: 'label-bold' + = f.number_field :throttle_authenticated_packages_api_period_in_seconds, class: 'form-control gl-form-input' + + = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index d57ae94b084..632aeec6ce3 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -17,7 +17,7 @@ = f.check_box :plantuml_enabled, class: 'form-check-input' = f.label :plantuml_enabled, _('Enable PlantUML'), class: 'form-check-label' .form-group - = f.label :plantuml_url, 'PlantUML URL', class: 'label-bold' + = f.label :plantuml_url, _('PlantUML URL'), class: 'label-bold' = f.text_field :plantuml_url, class: 'form-control gl-form-input', placeholder: 'http://your-plantuml-instance:8080' .form-text.text-muted Allow rendering of diff --git a/app/views/admin/application_settings/_prometheus.html.haml b/app/views/admin/application_settings/_prometheus.html.haml index 468c1786d6f..f102b3d580b 100644 --- a/app/views/admin/application_settings/_prometheus.html.haml +++ b/app/views/admin/application_settings/_prometheus.html.haml @@ -3,31 +3,25 @@ %fieldset %p - Enable a Prometheus metrics endpoint at - %code= metrics_path - to expose a variety of statistics on the health and performance of GitLab. Additional information on authenticating and connecting to the metrics endpoint is available - = link_to 'here', admin_health_check_path - \. This setting requires a - = link_to 'restart', help_page_path('administration/restart_gitlab') - to take effect. + - link_to_restart = link_to(_('restart'), help_page_path('administration/restart_gitlab')) + = _('Enable a Prometheus metrics endpoint at %{metrics_path} to expose a variety of statistics on the health and performance of GitLab. Additional information on authenticating and connecting to the metrics endpoint is available %{link}.').html_safe % { metrics_path: "<code>#{metrics_path}</code>".html_safe, link: link_to(_('here'), admin_health_check_path) } + = _('This setting requires a %{link_to_restart} to take effect.').html_safe % { link_to_restart: link_to_restart } = link_to sprite_icon('question-o'), help_page_path('administration/monitoring/prometheus/index') .form-group .form-check = f.check_box :prometheus_metrics_enabled, class: 'form-check-input' = f.label :prometheus_metrics_enabled, class: 'form-check-label' do - Enable Prometheus Metrics + = _("Enable Prometheus Metrics") - unless Gitlab::Metrics.metrics_folder_present? .form-text.text-muted - %strong.cred WARNING: - Environment variable - %code prometheus_multiproc_dir - does not exist or is not pointing to a valid directory. + %strong.cred= _("WARNING:") + = _("Environment variable %{code_start}%{environment_variable}%{code_end} does not exist or is not pointing to a valid directory.").html_safe % { environment_variable: prometheus_multiproc_dir, code_start: '<code>'.html_safe, code_end: '</code>'.html_safe } = link_to sprite_icon('question-o'), help_page_path('administration/monitoring/prometheus/gitlab_metrics', anchor: 'metrics-shared-directory') .form-group - = f.label :metrics_method_call_threshold, 'Method Call Threshold (ms)', class: 'label-bold' + = f.label :metrics_method_call_threshold, _('Method Call Threshold (ms)'), class: 'label-bold' = f.number_field :metrics_method_call_threshold, class: 'form-control gl-form-input' .form-text.text-muted A method call is only tracked when it takes longer to complete than the given amount of milliseconds. - = f.submit 'Save changes', class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml index edf6853a1aa..31576d54a04 100644 --- a/app/views/admin/application_settings/_repository_check.html.haml +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -17,7 +17,7 @@ = _("If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.") - clear_repository_checks_link = _('Clear all repository checks') - clear_repository_checks_message = _('This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?') - = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "gl-button btn btn-sm btn-danger" + = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "gl-button btn btn-sm btn-danger gl-mt-3" .sub-section %h4= _("Housekeeping") diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 62d6c973efe..12a9f949750 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -6,14 +6,14 @@ .form-check = f.check_box :password_authentication_enabled_for_web, class: 'form-check-input' = f.label :password_authentication_enabled_for_web, class: 'form-check-label' do - Password authentication enabled for web interface + = _('Password authentication enabled for web interface') .form-text.text-muted - When disabled, an external authentication provider must be used. + = _('When disabled, an external authentication provider must be used.') .form-group .form-check = f.check_box :password_authentication_enabled_for_git, class: 'form-check-input' = f.label :password_authentication_enabled_for_git, class: 'form-check-label' do - Password authentication enabled for Git over HTTP(S) + = _('Password authentication enabled for Git over HTTP(S)') .form-text.text-muted When disabled, a Personal Access Token - if Gitlab::Auth::Ldap::Config.enabled? @@ -26,11 +26,11 @@ - oauth_providers_checkboxes.each do |source| = source .form-group - = f.label :two_factor_authentication, 'Two-factor authentication', class: 'label-bold' + = f.label :two_factor_authentication, _('Two-factor authentication'), class: 'label-bold' .form-check = f.check_box :require_two_factor_authentication, class: 'form-check-input' = f.label :require_two_factor_authentication, class: 'form-check-label' do - Require all users to set up Two-factor authentication + = _('Require all users to set up two-factor authentication') .form-group = f.label :admin_mode, _('Admin Mode'), class: 'label-bold' = sprite_icon('lock', css_class: 'gl-icon') @@ -50,19 +50,19 @@ 'https://docs.gitlab.com/ee/user/profile/unknown_sign_in_notification.html', target: '_blank' .form-group - = f.label :two_factor_authentication, 'Two-factor grace period (hours)', class: 'label-bold' + = f.label :two_factor_authentication, _('Two-factor grace period (hours)'), class: 'label-bold' = f.number_field :two_factor_grace_period, min: 0, class: 'form-control gl-form-input', placeholder: '0' - .form-text.text-muted Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication + .form-text.text-muted= _('Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication') .form-group - = f.label :home_page_url, 'Home page URL', class: 'label-bold' + = f.label :home_page_url, _('Home page URL'), class: 'label-bold' = f.text_field :home_page_url, class: 'form-control gl-form-input', placeholder: 'http://company.example.com', :'aria-describedby' => 'home_help_block' - %span.form-text.text-muted#home_help_block We will redirect non-logged in users to this page + %span.form-text.text-muted#home_help_block= _("We will redirect non-logged in users to this page") .form-group = f.label :after_sign_out_path, _('After sign-out path'), class: 'label-bold' = f.text_field :after_sign_out_path, class: 'form-control gl-form-input', placeholder: 'http://company.example.com', :'aria-describedby' => 'after_sign_out_path_help_block' - %span.form-text.text-muted#after_sign_out_path_help_block We will redirect users to this page after they sign out + %span.form-text.text-muted#after_sign_out_path_help_block= _("We will redirect users to this page after they sign out") .form-group = f.label :sign_in_text, _('Sign-in text'), class: 'label-bold' = f.text_area :sign_in_text, class: 'form-control gl-form-input', rows: 4 .form-text.text-muted Markdown enabled - = f.submit 'Save changes', class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml index 2086fbc9d32..011bce3ca99 100644 --- a/app/views/admin/application_settings/_spam.html.haml +++ b/app/views/admin/application_settings/_spam.html.haml @@ -6,25 +6,25 @@ .form-check = f.check_box :recaptcha_enabled, class: 'form-check-input' = f.label :recaptcha_enabled, class: 'form-check-label' do - Enable reCAPTCHA + = _("Enable reCAPTCHA") %span.form-text.text-muted#recaptcha_help_block = _('Helps prevent bots from creating accounts.') .form-group .form-check = f.check_box :login_recaptcha_protection_enabled, class: 'form-check-input' = f.label :login_recaptcha_protection_enabled, class: 'form-check-label' do - Enable reCAPTCHA for login + = _("Enable reCAPTCHA for login") %span.form-text.text-muted#recaptcha_help_block = _('Helps prevent bots from brute-force attacks.') .form-group - = f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'label-bold' + = f.label :recaptcha_site_key, _('reCAPTCHA Site Key'), class: 'label-bold' = f.text_field :recaptcha_site_key, class: 'form-control gl-form-input' .form-text.text-muted - Generate site and private keys at + = _("Generate site and private keys at") %a{ href: 'http://www.google.com/recaptcha', target: 'blank' } http://www.google.com/recaptcha .form-group - = f.label :recaptcha_private_key, 'reCAPTCHA Private Key', class: 'label-bold' + = f.label :recaptcha_private_key, _('reCAPTCHA Private Key'), class: 'label-bold' .form-group = f.text_field :recaptcha_private_key, class: 'form-control gl-form-input' @@ -41,10 +41,10 @@ = f.check_box :akismet_enabled, class: 'form-check-input' = f.label :akismet_enabled, class: 'form-check-label' do Enable Akismet - %span.form-text.text-muted#akismet_help_block Helps prevent bots from creating issues + %span.form-text.text-muted#akismet_help_block= _("Helps prevent bots from creating issues") .form-group - = f.label :akismet_api_key, 'Akismet API Key', class: 'label-bold' + = f.label :akismet_api_key, _('Akismet API Key'), class: 'label-bold' = f.text_field :akismet_api_key, class: 'form-control gl-form-input' .form-text.text-muted Generate API key at @@ -54,21 +54,21 @@ .form-check = f.check_box :unique_ips_limit_enabled, class: 'form-check-input' = f.label :unique_ips_limit_enabled, class: 'form-check-label' do - Limit sign in from multiple ips + = _("Limit sign in from multiple ips") %span.form-text.text-muted#unique_ip_help_block - Helps prevent malicious users hide their activity + = _("Helps prevent malicious users hide their activity") .form-group - = f.label :unique_ips_limit_per_user, 'IPs per user', class: 'label-bold' + = f.label :unique_ips_limit_per_user, _('IPs per user'), class: 'label-bold' = f.number_field :unique_ips_limit_per_user, class: 'form-control gl-form-input' .form-text.text-muted - Maximum number of unique IPs per user + = _("Maximum number of unique IPs per user") .form-group - = f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'label-bold' + = f.label :unique_ips_limit_time_window, _('IP expiration time'), class: 'label-bold' = f.number_field :unique_ips_limit_time_window, class: 'form-control gl-form-input' .form-text.text-muted - How many seconds an IP will be counted towards the limit + = _("How many seconds an IP will be counted towards the limit") .form-group .form-check @@ -78,5 +78,9 @@ .form-group = f.label :spam_check_endpoint_url, _('URL of the external Spam Check endpoint'), class: 'label-bold' = f.text_field :spam_check_endpoint_url, class: 'form-control gl-form-input' + .form-group + = f.label :spam_check_api_key, _('Spam Check API Key'), class: 'gl-font-weight-bold' + = f.text_field :spam_check_api_key, class: 'form-control gl-form-input' + .form-text.text-muted= _('The API key used by GitLab for accessing the Spam Check service endpoint') - = f.submit 'Save changes', class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml index 482466c4b3b..d6e31a24cf6 100644 --- a/app/views/admin/application_settings/_terminal.html.haml +++ b/app/views/admin/application_settings/_terminal.html.haml @@ -3,9 +3,8 @@ %fieldset .form-group - = f.label :terminal_max_session_time, 'Max session time', class: 'label-bold' + = f.label :terminal_max_session_time, _('Max session time'), class: 'label-bold' = f.number_field :terminal_max_session_time, class: 'form-control gl-form-input' .form-text.text-muted - Maximum time for web terminal websocket connection (in seconds). - 0 for unlimited. - = f.submit 'Save changes', class: "gl-button btn btn-confirm" + = _('Maximum time for web terminal websocket connection (in seconds). 0 for unlimited.') + = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index a2d61bd010f..64e8751bf31 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -8,11 +8,10 @@ .form-check = f.check_box :version_check_enabled, class: 'form-check-input' = f.label :version_check_enabled, class: 'form-check-label' do - Enable version check + = _("Enable version check") .form-text.text-muted - GitLab will inform you if a new version is available. - = link_to 'Learn more', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'version-check') - about what information is shared with GitLab Inc. + = _("GitLab will inform you if a new version is available.") + = _("%{link_start}Learn more%{link_end} about what information is shared with GitLab Inc.").html_safe % { link_start: "<a href='#{help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check")}'>".html_safe, link_end: '</a>'.html_safe } .form-group - can_be_configured = @application_setting.usage_ping_can_be_configured? .form-check @@ -28,7 +27,7 @@ %p.mb-2= s_('%{usage_ping_link_start}Learn more%{usage_ping_link_end} about what information is shared with GitLab Inc.').html_safe % { usage_ping_link_start: usage_ping_link_start, usage_ping_link_end: '</a>'.html_safe } %button.gl-button.btn.btn-default.js-payload-preview-trigger{ type: 'button', data: { payload_selector: ".#{payload_class}" } } - .spinner.js-spinner.d-none + .gl-spinner.js-spinner.gl-display-none.gl-mr-2 .js-text.d-inline= _('Preview payload') %pre.usage-data.js-syntax-highlight.code.highlight.mt-2.d-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } - else @@ -37,4 +36,4 @@ - deactivating_usage_ping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: deactivating_usage_ping_path } = s_('For more information, see the documentation on %{deactivating_usage_ping_link_start}deactivating the usage ping%{deactivating_usage_ping_link_end}.').html_safe % { deactivating_usage_ping_link_start: deactivating_usage_ping_link_start, deactivating_usage_ping_link_end: '</a>'.html_safe } - = f.submit 'Save changes', class: "gl-button btn btn-confirm" + = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/_whats_new.html.haml b/app/views/admin/application_settings/_whats_new.html.haml new file mode 100644 index 00000000000..70ba994d21e --- /dev/null +++ b/app/views/admin/application_settings/_whats_new.html.haml @@ -0,0 +1,13 @@ += form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-whats-new-settings'), html: { class: 'fieldset-form whats-new-settings' } do |f| + = form_errors(@application_setting) + + - whats_new_variants.keys.each do |variant| + .form-check.gl-mb-4 + = f.radio_button :whats_new_variant, variant, class: 'form-check-input' + = f.label :whats_new_variant, value: variant, class: 'form-check-label' do + .font-weight-bold + = whats_new_variants_label(variant) + .option-description + = whats_new_variants_description(variant) + + = f.submit _('Save changes'), class: "gl-button btn btn-confirm" diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index 86226a9de2f..217225e6186 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -112,3 +112,4 @@ = render 'admin/application_settings/third_party_offers' = render 'admin/application_settings/snowplow' = render 'admin/application_settings/eks' += render 'admin/application_settings/floc' diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 72716e76013..72a27e4523f 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -24,6 +24,17 @@ .settings-content = render 'ip_limits' +%section.settings.as-packages-limits.no-animate#js-packages-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'packages_limits_content' } } + .settings-header + %h4 + = _('Package Registry Rate Limits') + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Configure specific limits for Packages API requests that supersede the general user and IP rate limits.') + .settings-content + = render 'package_registry_limits' + %section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'outbound_requests_content' } } .settings-header %h4 diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index fd5ce890648..17bf9ba84a2 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -13,6 +13,17 @@ .settings-content = render 'email' +%section.settings.as-whats-new-page.no-animate#js-whats-new-settings{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _("What's new") + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _("Configure What's new drawer and content.") + .settings-content + = render 'whats_new' + %section.settings.as-help-page.no-animate#js-help-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index 4365d8937bd..111cc9c5d7c 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -2,17 +2,16 @@ - page_title _("Repository") - @content_class = "limit-container-width" unless fluid_layout -- if Feature.enabled?(:global_default_branch_name, default_enabled: true) - %section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4 - = _('Default initial branch name') - %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } - = expanded_by_default? ? _('Collapse') : _('Expand') - %p - = _('Set the default name of the initial branch when creating new repositories through the user interface.') - .settings-content - = render 'initial_branch_name' +%section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) } + .settings-header + %h4 + = _('Default initial branch name') + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } + = expanded_by_default? ? _('Collapse') : _('Expand') + %p + = _('Set the default name of the initial branch when creating new repositories through the user interface.') + .settings-content + = render 'initial_branch_name' %section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded_by_default?) } .settings-header diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml index 9ba72caa88e..bab9fa02928 100644 --- a/app/views/admin/background_jobs/show.html.haml +++ b/app/views/admin/background_jobs/show.html.haml @@ -1,7 +1,10 @@ - page_title _("Background Jobs") -%h3.page-title Background Jobs -%p.light GitLab uses #{link_to "sidekiq", "http://sidekiq.org/"} library for async job processing +%h3.page-title= _('Background Jobs') +%p.light + - sidekiq_link_url = 'http://sidekiq.org/' + - sidekiq_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: sidekiq_link_url } + = html_escape(_('GitLab uses %{linkStart}Sidekiq%{linkEnd} to process background jobs')) % { linkStart: sidekiq_link_start, linkEnd: '</a>'.html_safe } %hr .card.gl-rounded-0 diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index 9a4bb9b0a48..fe5759ecdbf 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -4,7 +4,7 @@ - if @broadcast_message.message.present? = render_broadcast_message(@broadcast_message) - else - Your message here + = _('Your message here') .d-flex.justify-content-center .broadcast-message.broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) } = sprite_icon('bullhorn', css_class: 'vertical-align-text-top') @@ -12,7 +12,7 @@ - if @broadcast_message.message.present? = render_broadcast_message(@broadcast_message) - else - Your message here + = _('Your message here') = form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f| = form_errors(@broadcast_message) @@ -55,7 +55,7 @@ = _('Allow users to dismiss the broadcast message') .form-group.row.js-toggle-colors-container.toggle-colors.hide .col-sm-2.col-form-label - = f.label :font, "Font Color" + = f.label :font, _("Font Color") .col-sm-10 = f.color_field :font, class: "form-control gl-form-input text-font-color" .form-group.row @@ -77,6 +77,6 @@ = f.datetime_select :ends_at, {}, class: 'form-control form-control-inline' .form-actions - if @broadcast_message.persisted? - = f.submit "Update broadcast message", class: "btn gl-button btn-confirm" + = f.submit _("Update broadcast message"), class: "btn gl-button btn-confirm" - else - = f.submit "Add broadcast message", class: "btn gl-button btn-confirm" + = f.submit _("Add broadcast message"), class: "btn gl-button btn-confirm" diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index e34808665bb..2dbb804d537 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -44,7 +44,7 @@ trigger: "focus", content: s_("AdminArea|All users created in the instance, including users who are not %{billable_users_link_start}billable users%{billable_users_link_end}.").html_safe % { billable_users_link_start: billable_users_link_start, billable_users_link_end: '</a>'.html_safe }, } } - = sprite_icon('question', size: 16, css_class: 'gl-text-gray-700') + = sprite_icon('question-o', size: 16, css_class: 'gl-text-blue-600') .gl-mt-3.text-uppercase = s_('AdminArea|Users') = link_to(s_('AdminArea|Users statistics'), admin_dashboard_stats_path, class: "text-capitalize gl-ml-2") diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml index 0eaf7b60b25..b0b12a01aed 100644 --- a/app/views/admin/deploy_keys/new.html.haml +++ b/app/views/admin/deploy_keys/new.html.haml @@ -6,5 +6,5 @@ = form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f| = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } .form-actions - = f.submit 'Create', class: 'btn gl-button btn-confirm' + = f.submit 'Create', class: 'btn gl-button btn-confirm', data: { qa_selector: "add_deploy_key_button" } = link_to 'Cancel', admin_deploy_keys_path, class: 'btn gl-button btn-default btn-cancel' diff --git a/app/views/admin/dev_ops_report/_card.html.haml b/app/views/admin/dev_ops_report/_card.html.haml deleted file mode 100644 index dd6e5c0f108..00000000000 --- a/app/views/admin/dev_ops_report/_card.html.haml +++ /dev/null @@ -1,25 +0,0 @@ -.devops-card-wrapper - .devops-card{ class: "devops-card-#{score_level(card.percentage_score)}" } - .devops-card-title - %h3 - = card.title - .light-text - = card.description - .board-card-scores - .board-card-score - .board-card-score-value - = format_score(card.instance_score) - .board-card-score-name= _('You') - .board-card-score - .board-card-score-value - = format_score(card.leader_score) - .board-card-score-name= _('Lead') - .board-card-score-big - = number_to_percentage(card.percentage_score, precision: 1) - .board-card-buttons - - if card.blog - %a.btn-svg{ href: card.blog } - = sprite_icon('information-o') - - if card.docs - %a.btn-svg{ href: card.docs } - = sprite_icon('question-o') diff --git a/app/views/admin/dev_ops_report/_no_data.html.haml b/app/views/admin/dev_ops_report/_no_data.html.haml deleted file mode 100644 index e540a4e2bce..00000000000 --- a/app/views/admin/dev_ops_report/_no_data.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -.container.devops-empty - .col-sm-12.justify-content-center.text-center - = custom_icon('dev_ops_report_no_data') - %h4= _('Data is still calculating...') - %p - = _('It may be several days before you see feature usage data.') - = link_to _('Our documentation includes an example DevOps Score report.'), help_page_path('user/admin_area/analytics/dev_ops_report'), target: '_blank' diff --git a/app/views/admin/dev_ops_report/_report.html.haml b/app/views/admin/dev_ops_report/_report.html.haml index 95ef1298d03..dbd0020e382 100644 --- a/app/views/admin/dev_ops_report/_report.html.haml +++ b/app/views/admin/dev_ops_report/_report.html.haml @@ -4,29 +4,7 @@ = render 'callout' - if !usage_ping_enabled - #js-devops-empty-state{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_usage_ping_link: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/usage_ping/index.md') } } -- elsif @metric.blank? - = render 'no_data' + #js-devops-usage-ping-disabled{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_usage_ping_link: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/usage_ping/index.md') } } - else - .devops - .gl-my-3.gl-text-gray-400{ data: { testid: 'devops-score-note-text' } } - = s_('DevopsReport|DevOps score metrics are based on usage over the last 30 days. Last updated: %{timestamp}.').html_safe % { timestamp: @metric.created_at.strftime('%Y-%m-%d %H:%M') } - .devops-header - %h2.devops-header-title{ class: "devops-#{score_level(@metric.average_percentage_score)}-score" } - = number_to_percentage(@metric.average_percentage_score, precision: 1) - .devops-header-subtitle - = s_('DevopsReport|DevOps') - %br - = s_('DevopsReport|Score') - = link_to sprite_icon('question-o', css_class: 'devops-header-icon'), help_page_path('user/admin_area/analytics/dev_ops_report') + #js-devops-score{ data: { devops_score_metrics: devops_score_metrics(@metric).to_json, devops_report_docs_path: help_page_path('user/admin_area/analytics/dev_ops_report'), no_data_image_path: image_path('dev_ops_report_no_data.svg') } } - .devops-cards.board-card-container - - @metric.cards.each do |card| - = render 'card', card: card - - .devops-steps.d-none.d-lg-block - - @metric.idea_to_production_steps.each_with_index do |step, index| - .devops-step{ class: "devops-#{score_level(step.percentage_score)}-score" } - = custom_icon("i2p_step_#{index + 1}") - %h4.devops-step-title - = step.title diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index df7af86e089..bbc65850794 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -15,7 +15,7 @@ = markdown_field(group, :description) .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex - %span.badge.badge-pill + %span.badge.badge-muted.badge-pill.gl-badge.sm = storage_counter(group.storage_size) = render_if_exists 'admin/namespace_plan_badge', namespace: group, css_class: 'gl-ml-5 gl-mr-0' diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index 78f0fd325fb..a289cea0d5a 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -23,8 +23,8 @@ %code= metrics_url(token: Gitlab::CurrentSettings.health_check_access_token) = render_if_exists 'admin/health_check/health_check_url' %hr -.card - .card-header +.gl-card + .gl-card-header Current Status: - if no_errors = sprite_icon('check', css_class: 'cgreen') @@ -32,7 +32,7 @@ - else = sprite_icon('warning-solid', css_class: 'cred') #{ s_('HealthCheck|Unhealthy') } - .card-body + .gl-card-body - if no_errors #{ s_('HealthCheck|No Health Problems Detected') } - else diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml index 61af7535c1e..a7f947f96ea 100644 --- a/app/views/admin/hook_logs/_index.html.haml +++ b/app/views/admin/hook_logs/_index.html.haml @@ -4,7 +4,7 @@ = _('Recent Deliveries') %p= _('When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong.') .col-lg-9 - - if hook_logs.any? + - if hook_logs.present? %table.table %thead %tr diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml index a357c3d9d34..16661efce04 100644 --- a/app/views/admin/labels/_label.html.haml +++ b/app/views/admin/labels/_label.html.haml @@ -3,5 +3,5 @@ .label-actions-list = link_to edit_admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do = sprite_icon('pencil') - = link_to admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary hover-red js-remove-row label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do + = link_to admin_label_path(label), class: 'btn btn-default gl-button btn-default-tertiary hover-red js-remove-label label-action has-tooltip', title: _('Delete'), data: { placement: 'bottom', confirm: "Delete this label? Are you sure?" }, aria_label: _('Delete'), method: :delete, remote: true do = sprite_icon('remove') diff --git a/app/views/admin/labels/destroy.js.haml b/app/views/admin/labels/destroy.js.haml deleted file mode 100644 index 5ee53088230..00000000000 --- a/app/views/admin/labels/destroy.js.haml +++ /dev/null @@ -1,3 +0,0 @@ -- if @labels.size == 0 - var emptyState = document.querySelector('.labels .nothing-here-block.hidden'); - if (emptyState) emptyState.classList.remove('hidden'); diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index 7e505729213..6f7cea85ed1 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -1,7 +1,7 @@ .js-projects-list-holder - if @projects.any? %ul.projects-list.content-list.admin-projects - - @projects.each_with_index do |project| + - @projects.each do |project| %li.project-row{ class: ('no-description' if project.description.blank?) } .controls = link_to _('Edit'), edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn gl-button btn-default" diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml index 6c75dfe9733..9d42a2bfa93 100644 --- a/app/views/admin/requests_profiles/index.html.haml +++ b/app/views/admin/requests_profiles/index.html.haml @@ -4,9 +4,7 @@ = page_title .bs-callout.clearfix - Pass the header - %code X-Profile-Token: #{@profile_token} - to profile the request + = html_escape(_('Pass the header %{codeOpen} X-Profile-Token: %{profile_token} %{codeClose} to profile the request')) % { profile_token: @profile_token, codeOpen: '<code>'.html_safe, codeClose: '</code>'.html_safe } - if @profiles.present? .gl-mt-3 @@ -21,4 +19,4 @@ admin_requests_profile_path(profile) - else %p - No profiles found + = _('No profiles found') diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index a38615d9b1b..359e5b411b1 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -5,7 +5,7 @@ .col-sm-6 .bs-callout %p - = (_"Runners are processes that pick up and execute CI/CD jobs for GitLab.") + = _("Runners are processes that pick up and execute CI/CD jobs for GitLab.") %br = _('You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.') %br diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 705716c09b7..d911f35d946 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -1,11 +1,11 @@ - add_page_specific_style 'page_bundles/ci_status' -- page_title @runner.short_sha +- breadcrumb_title @runner.short_sha +- page_title "##{@runner.id} (#{@runner.short_sha})" - add_to_breadcrumbs _('Runners'), admin_runners_path -- breadcrumb_title page_title - if Feature.enabled?(:runner_detailed_view_vue_ui, current_user, default_enabled: :yaml) - #js-runner-detail{ data: {runner_id: @runner.id} } + #js-runner-details{ data: {runner_id: @runner.id} } - else %h2.page-title = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id }) @@ -46,9 +46,10 @@ %tr %td = form_tag admin_runner_path(@runner), id: 'runner-projects-search', class: 'form-inline', method: :get do - .form-group - = search_field_tag :search, params[:search], class: 'form-control', spellcheck: false - = submit_tag 'Search', class: 'btn' + .input-group + = search_field_tag :search, params[:search], class: 'form-control gl-form-input', spellcheck: false + .input-group-append + = submit_tag _('Search'), class: 'gl-button btn btn-default' %td - @projects.each do |project| @@ -59,7 +60,7 @@ .float-right = form_for project.runner_projects.new, url: admin_namespace_project_runner_projects_path(project.namespace, project), method: :post do |f| = f.hidden_field :runner_id, value: @runner.id - = f.submit 'Enable', class: 'gl-button btn btn-sm' + = f.submit _('Enable'), class: 'gl-button btn btn-sm' = paginate_without_count @projects .col-md-6 diff --git a/app/views/admin/spam_logs/index.html.haml b/app/views/admin/spam_logs/index.html.haml index 40fbc559d72..2a36c991ed2 100644 --- a/app/views/admin/spam_logs/index.html.haml +++ b/app/views/admin/spam_logs/index.html.haml @@ -1,22 +1,22 @@ - page_title _("Spam Logs") -%h3.page-title Spam Logs +%h3.page-title= _('Spam Logs') %hr - if @spam_logs.present? .table-holder %table.table %thead %tr - %th Date - %th User - %th Source IP - %th API? - %th Recaptcha verified? - %th Type - %th Title - %th Description - %th Primary Action + %th= _('Date') + %th= _('User') + %th= _('Source IP') + %th= _('API?') + %th= _('Recaptcha verified?') + %th= _('Type') + %th= _('Title') + %th= _('Description') + %th= _('Primary Action') %th = render @spam_logs = paginate @spam_logs, theme: 'gitlab' - else - %h4 There are no Spam Logs + %h4= _('There are no Spam Logs') diff --git a/app/views/admin/users/_ban_user.html.haml b/app/views/admin/users/_ban_user.html.haml new file mode 100644 index 00000000000..229c88adb7f --- /dev/null +++ b/app/views/admin/users/_ban_user.html.haml @@ -0,0 +1,9 @@ +- if ban_feature_available? + .card.border-warning + .card-header.bg-warning.gl-text-white + = s_('AdminUsers|Ban user') + .card-body + = user_ban_effects + %br + %button.btn.gl-button.btn-warning.js-confirm-modal-button{ data: user_ban_data(user) } + = s_('AdminUsers|Ban user') diff --git a/app/views/admin/users/_cohorts.html.haml b/app/views/admin/users/_cohorts.html.haml index 013c6072165..25b30adc5be 100644 --- a/app/views/admin/users/_cohorts.html.haml +++ b/app/views/admin/users/_cohorts.html.haml @@ -1,4 +1 @@ -- if @cohorts - = render 'cohorts_table' -- else - #js-cohorts-empty-state{ data: { empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_usage_ping_link: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('user/admin_area/analytics/user_cohorts') } } += render 'cohorts_table' diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index b3ed8369263..9d62c19e2fc 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -3,40 +3,38 @@ = form_errors(@user) %fieldset - %legend Account + %legend= _('Account') .form-group.row .col-sm-2.col-form-label = f.label :name .col-sm-10 = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input' - %span.help-inline * required + %span.help-inline * #{_('required')} .form-group.row .col-sm-2.col-form-label = f.label :username .col-sm-10 = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input' - %span.help-inline * required + %span.help-inline * #{_('required')} .form-group.row .col-sm-2.col-form-label = f.label :email .col-sm-10 = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input' - %span.help-inline * required + %span.help-inline * #{_('required')} - if @user.new_record? %fieldset - %legend Password + %legend= _('Password') .form-group.row .col-sm-2.col-form-label = f.label :password .col-sm-10 %strong - Reset link will be generated and sent to the user. - %br - User will be forced to set the password on first sign in. + = _('Reset link will be generated and sent to the user. %{break} User will be forced to set the password on first sign in.').html_safe % { break: '<br />'.html_safe } - else %fieldset - %legend Password + %legend= _('Password') .form-group.row .col-sm-2.col-form-label = f.label :password @@ -55,7 +53,7 @@ = render_if_exists 'admin/users/limits', f: f %fieldset - %legend Profile + %legend= _('Profile') .form-group.row .col-sm-2.col-form-label = f.label :avatar @@ -87,8 +85,8 @@ .form-actions - if @user.new_record? - = f.submit 'Create user', class: "btn gl-button btn-confirm" - = link_to 'Cancel', admin_users_path, class: "gl-button btn btn-default btn-cancel" + = f.submit _('Create user'), class: "btn gl-button btn-confirm" + = link_to _('Cancel'), admin_users_path, class: "gl-button btn btn-default btn-cancel" - else - = f.submit 'Save changes', class: "btn gl-button btn-confirm" - = link_to 'Cancel', admin_user_path(@user), class: "gl-button btn btn-default btn-cancel" + = f.submit _('Save changes'), class: "btn gl-button btn-confirm" + = link_to _('Cancel'), admin_user_path(@user), class: "gl-button btn btn-default btn-cancel" diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index ade3581e5b9..be04e87f8b9 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -3,6 +3,9 @@ - if @user.blocked_pending_approval? %span.cred = s_('AdminUsers|(Pending approval)') + - elsif @user.banned? + %span.cred + = s_('AdminUsers|(Banned)') - elsif @user.blocked? %span.cred = s_('AdminUsers|(Blocked)') diff --git a/app/views/admin/users/_profile.html.haml b/app/views/admin/users/_profile.html.haml index 4fcb9aad343..e90dab68b39 100644 --- a/app/views/admin/users/_profile.html.haml +++ b/app/views/admin/users/_profile.html.haml @@ -1,31 +1,31 @@ .card .card-header - Profile + = _('Profile') %ul.content-list %li - %span.light Member since + %span.light= _('Member since') %strong= user.created_at.to_s(:medium) - unless user.public_email.blank? %li - %span.light E-mail: + %span.light= _('E-mail:') %strong= link_to user.public_email, "mailto:#{user.public_email}" - unless user.skype.blank? %li - %span.light Skype: + %span.light= _('Skype:') %strong= link_to user.skype, "skype:#{user.skype}" - unless user.linkedin.blank? %li - %span.light LinkedIn: + %span.light= _('LinkedIn:') %strong= link_to user.linkedin, "https://www.linkedin.com/in/#{user.linkedin}" - unless user.twitter.blank? %li - %span.light Twitter: + %span.light= _('Twitter:') %strong= link_to user.twitter, "https://twitter.com/#{user.twitter}" - unless user.website_url.blank? %li - %span.light Website: + %span.light= _('Website:') %strong= link_to user.short_website_url, user.full_website_url - unless user.location.blank? %li - %span.light Location: + %span.light= _('Location:') %strong= user.location diff --git a/app/views/admin/users/_projects.html.haml b/app/views/admin/users/_projects.html.haml index 81cfb71af16..a9f5c560b41 100644 --- a/app/views/admin/users/_projects.html.haml +++ b/app/views/admin/users/_projects.html.haml @@ -1,13 +1,13 @@ - if local_assigns.has_key?(:contributed_projects) && contributed_projects.present? .card.contributed-projects - .card-header Projects contributed to + .card-header= _('Projects contributed to') = render 'shared/projects/list', projects: contributed_projects.sort_by(&:star_count).reverse, projects_limit: 5, stars: true, avatar: false - if local_assigns.has_key?(:projects) && projects.present? .card - .card-header Personal projects + .card-header= _('Personal projects') = render 'shared/projects/list', projects: projects.sort_by(&:star_count).reverse, projects_limit: 10, stars: true, avatar: false diff --git a/app/views/admin/users/_tabs.html.haml b/app/views/admin/users/_tabs.html.haml new file mode 100644 index 00000000000..1a3239897eb --- /dev/null +++ b/app/views/admin/users/_tabs.html.haml @@ -0,0 +1,7 @@ +%ul.nav-links.nav-tabs.nav.js-users-tabs{ role: 'tablist' } + %li.nav-item{ role: 'presentation' } + %a.nav-link{ href: admin_users_path, class: active_when(current_page?(admin_users_path)), role: 'tab' } + = s_('AdminUsers|Users') + %li.nav-item{ role: 'presentation' } + %a.nav-link{ href: cohorts_admin_users_path, class: active_when(current_page?(cohorts_admin_users_path)), role: 'tab' } + = s_('AdminUsers|Cohorts') diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index f2920579057..2816a1061b9 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -21,13 +21,13 @@ = user.last_activity_on.nil? ? _('Never') : l(user.last_activity_on, format: :admin) - unless user.internal? .table-section.section-20.table-button-footer - .table-action-buttons - = link_to _('Edit'), edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn gl-button btn-default' + .table-action-buttons{ data: { testid: "user-actions-#{user.id}" } } + = link_to _('Edit'), edit_admin_user_path(user), class: 'btn gl-button btn-default' - unless user == current_user - %button.dropdown-new.btn.gl-button.btn-default{ type: 'button', data: { testid: "user-action-button-#{user.id}", toggle: 'dropdown' } } + %button.dropdown-new.btn.gl-button.btn-default{ type: 'button', data: { testid: "dropdown-toggle", toggle: 'dropdown' } } = sprite_icon('settings') = sprite_icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-right{ data: { testid: "user-action-dropdown-#{user.id}" } } + %ul.dropdown-menu.dropdown-menu-right %li.dropdown-header = _('Settings') %li diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index c79b2e978f2..e4438f38a47 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -7,39 +7,44 @@ = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do = link_to admin_users_path do = s_('AdminUsers|Active') - %small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts) + %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.active_without_ghosts) = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do = link_to admin_users_path(filter: "admins") do = s_('AdminUsers|Admins') - %small.badge.badge-pill= limited_counter_with_delimiter(User.admins) + %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.admins) = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do = link_to admin_users_path(filter: 'two_factor_enabled') do = s_('AdminUsers|2FA Enabled') - %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor) + %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.with_two_factor) = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do = link_to admin_users_path(filter: 'two_factor_disabled') do = s_('AdminUsers|2FA Disabled') - %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor) + %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.without_two_factor) = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do = link_to admin_users_path(filter: 'external') do = s_('AdminUsers|External') - %small.badge.badge-pill= limited_counter_with_delimiter(User.external) + %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.external) = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do = link_to admin_users_path(filter: "blocked") do = s_('AdminUsers|Blocked') - %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked) + %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.blocked) + - if ban_feature_available? + = nav_link(html_options: { class: active_when(params[:filter] == 'banned') }) do + = link_to admin_users_path(filter: "banned") do + = s_('AdminUsers|Banned') + %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.banned) = nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do = link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do = s_('AdminUsers|Pending approval') - %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval) + %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.blocked_pending_approval) = nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do = link_to admin_users_path(filter: "deactivated") do = s_('AdminUsers|Deactivated') - %small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated) + %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.deactivated) = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do = link_to admin_users_path(filter: "wop") do = s_('AdminUsers|Without projects') - %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects) + %small.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(User.without_projects) .nav-controls = render_if_exists 'admin/users/admin_email_users' = render_if_exists 'admin/users/admin_export_user_permissions' @@ -68,7 +73,7 @@ = link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do = title -- if Feature.enabled?(:vue_admin_users) +- if Feature.enabled?(:vue_admin_users, default_enabled: :yaml) #js-admin-users-app{ data: admin_users_data_attributes(@users) } .gl-spinner-container.gl-my-7 %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } } @@ -83,6 +88,6 @@ = render partial: 'admin/users/user', collection: @users -= paginate @users, theme: "gitlab" += paginate_collection @users = render partial: 'admin/users/modals' diff --git a/app/views/admin/users/cohorts.html.haml b/app/views/admin/users/cohorts.html.haml new file mode 100644 index 00000000000..3f3d22fa410 --- /dev/null +++ b/app/views/admin/users/cohorts.html.haml @@ -0,0 +1,7 @@ +- page_title _("Users") + += render 'tabs' + +.tab-content + .tab-pane.active + = render 'cohorts' diff --git a/app/views/admin/users/edit.html.haml b/app/views/admin/users/edit.html.haml index 7d10e839cd6..e3ebb691ba9 100644 --- a/app/views/admin/users/edit.html.haml +++ b/app/views/admin/users/edit.html.haml @@ -1,5 +1,5 @@ - page_title _("Edit"), @user.name, _("Users") %h3.page-title - Edit user: #{@user.name} + = _("Edit user: %{user_name}") % { user_name: @user.name } %hr = render 'form' diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index f9b631ed6cf..86b777d8458 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -1,17 +1,7 @@ - page_title _("Users") -%ul.nav-links.nav-tabs.nav.js-users-tabs{ role: 'tablist' } - %li.nav-item.js-users-tab-item{ role: 'presentation' } - %a.nav-link{ href: '#users', class: active_when(params[:tab] != 'cohorts'), data: { toggle: 'tab' }, role: 'tab' } - = s_('AdminUsers|Users') - %li.nav-item.js-users-tab-item{ role: 'presentation' } - %a.nav-link{ href: '#cohorts', class: active_when(params[:tab] == 'cohorts'), data: { toggle: 'tab' }, role: 'tab' } - = s_('AdminUsers|Cohorts') += render 'tabs' .tab-content - .tab-pane{ id: 'users', class: ('active' if params[:tab] != 'cohorts') } + .tab-pane.active = render 'users' - .tab-pane{ id: 'cohorts', class: ('active' if params[:tab] == 'cohorts') } - = render 'cohorts' - - diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml index 70a497f14ff..3ff726e1945 100644 --- a/app/views/admin/users/projects.html.haml +++ b/app/views/admin/users/projects.html.haml @@ -5,7 +5,7 @@ - if @user.groups.any? .card - .card-header Group projects + .card-header= _('Group projects') %ul.hover-list - @user.group_members.includes(:source).each do |group_member| # rubocop: disable CodeReuse/ActiveRecord - group = group_member.group @@ -24,12 +24,12 @@ - if @personal_projects.present? = render 'admin/users/projects', projects: @personal_projects - else - .nothing-here-block This user has no personal projects. + .nothing-here-block= _('This user has no personal projects.') .col-md-6 .card - .card-header Joined projects (#{@joined_projects.count}) + .card-header= _('Joined projects (%{projects_count})') % { projects_count: @joined_projects.count } %ul.hover-list - @joined_projects.sort_by(&:full_name).each do |project| - member = project.team.find_member(@user.id) @@ -41,10 +41,10 @@ - if member .float-right - if member.owner? - %span.light Owner + %span.light= _('Owner') - else %span.light.vertical-align-middle= member.human_access - if member.respond_to? :project - = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: 'Remove user from project' do + = link_to project_project_member_path(project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon gl-ml-3", title: _('Remove user from project') do = sprite_icon('close', size: 16, css_class: 'gl-icon') diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index c7ec3ab66d7..19cc29668f5 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -12,7 +12,7 @@ %li = image_tag avatar_icon_for_user(@user, 60), class: "avatar s60" %li - %span.light Profile page: + %span.light= _('Profile page:') %strong = link_to user_path(@user) do = @user.username @@ -20,25 +20,25 @@ .card .card-header - Account: + = _('Account:') %ul.content-list %li - %span.light Name: + %span.light= _('Name:') %strong= @user.name %li - %span.light Username: + %span.light= _('Username:') %strong = @user.username %li - %span.light Email: + %span.light= _('Email:') %strong = render partial: 'shared/email_with_badge', locals: { email: mail_to(@user.email), verified: @user.confirmed? } - @user.emails.each do |email| %li - %span.light Secondary email: + %span.light= _('Secondary email:') %strong = render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? } - = link_to remove_email_admin_user_path(@user, email), data: { confirm: "Are you sure you want to remove #{email.email}?" }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: 'Remove secondary email', id: "remove_email_#{email.id}" do + = link_to remove_email_admin_user_path(@user, email), data: { confirm: _("Are you sure you want to remove %{email}?") % { email: email.email } }, method: :delete, class: "btn btn-sm btn-danger gl-button btn-icon float-right", title: _('Remove secondary email'), id: "remove_email_#{email.id}" do = sprite_icon('close', size: 16, css_class: 'gl-icon') %li %span.light ID: @@ -50,65 +50,68 @@ = @user.namespace_id %li.two-factor-status - %span.light Two-factor Authentication: + %span.light= _('Two-factor Authentication:') %strong{ class: @user.two_factor_enabled? ? 'cgreen' : 'cred' } - if @user.two_factor_enabled? - Enabled - = link_to 'Disable', disable_two_factor_admin_user_path(@user), data: {confirm: 'Are you sure?'}, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: 'Disable Two-factor Authentication' + = _('Enabled') + = link_to _('Disable'), disable_two_factor_admin_user_path(@user), data: { confirm: _('Are you sure?') }, method: :patch, class: 'btn gl-button btn-sm btn-danger float-right', title: _('Disable Two-factor Authentication') - else - Disabled + = _('Disabled') = render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace %li - %span.light External User: + %span.light= _('External User:') %strong - = @user.external? ? "Yes" : "No" + = @user.external? ? _('Yes') : _('No') + + = render_if_exists 'admin/users/provisioned_by', user: @user + %li - %span.light Can create groups: + %span.light= _('Can create groups:') %strong - = @user.can_create_group ? "Yes" : "No" + = @user.can_create_group ? _('Yes') : _('No') %li - %span.light Personal projects limit: + %span.light= _('Personal projects limit:') %strong = @user.projects_limit %li - %span.light Member since: + %span.light= _('Member since:') %strong = @user.created_at.to_s(:medium) - if @user.confirmed_at %li - %span.light Confirmed at: + %span.light= _('Confirmed at:') %strong = @user.confirmed_at.to_s(:medium) - else %li - %span.light Confirmed: + %span.ligh= _('Confirmed:') %strong.cred - No + = _('No') %li - %span.light Current sign-in IP: + %span.light= _('Current sign-in IP:') %strong = @user.current_sign_in_ip || _('never') %li - %span.light Current sign-in at: + %span.light= _('Current sign-in at:') %strong = @user.current_sign_in_at&.to_s(:medium) || _('never') %li - %span.light Last sign-in IP: + %span.light= _('Last sign-in IP:') %strong = @user.last_sign_in_ip || _('never') %li - %span.light Last sign-in at: + %span.light= _('Last sign-in at:') %strong = @user.last_sign_in_at&.to_s(:medium) || _('never') %li - %span.light Sign-in count: + %span.light= _('Sign-in count:') %strong = @user.sign_in_count @@ -121,13 +124,13 @@ - if @user.ldap_user? %li - %span.light LDAP uid: + %span.light= _('LDAP uid:') %strong = @user.ldap_identity.extern_uid - if @user.created_by %li - %span.light Created by: + %span.light= _('Created by:') %strong = link_to @user.created_by.name, [:admin, @user.created_by] @@ -140,13 +143,13 @@ - if can_force_email_confirmation?(@user) .gl-card.border-info.gl-mb-5 .gl-card-header.bg-info.text-white - Confirm user + = _('Confirm user') .gl-card-body - if @user.unconfirmed_email.present? - email = " (#{@user.unconfirmed_email})" - %p This user has an unconfirmed email address#{email}. You may force a confirmation. + %p= _('This user has an unconfirmed email address %{email}. You may force a confirmation.') % { email: email } %br - = link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?', qa_selector: 'confirm_user_button' } + = link_to _('Confirm user'), confirm_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: _('Are you sure?'), qa_selector: 'confirm_user_button' } = render 'admin/users/user_detail_note' @@ -154,7 +157,7 @@ - if @user.deactivated? .gl-card.border-info.gl-mb-5 .gl-card-header.bg-info.text-white - Reactivate this user + = _('Reactivate this user') .gl-card-body = render partial: 'admin/users/user_activation_effects' %br @@ -163,7 +166,7 @@ - elsif @user.can_be_deactivated? .gl-card.border-warning.gl-mb-5 .gl-card-header.bg-warning.text-white - Deactivate this user + = _('Deactivate this user') .gl-card-body = user_deactivation_effects %br @@ -173,36 +176,51 @@ - if @user.blocked_pending_approval? = render 'admin/users/approve_user', user: @user = render 'admin/users/reject_pending_user', user: @user + - elsif @user.banned? + .gl-card.border-info.gl-mb-5 + .gl-card-header.gl-bg-blue-500.gl-text-white + = _('This user is banned') + .gl-card-body + %p= _('A banned user cannot:') + %ul + %li= _('Log in') + %li= _('Access Git repositories') + - link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path("user/admin_area/moderate_users", anchor: "ban-a-user") } + = s_('AdminUsers|Learn more about %{link_start}banned users.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + %p + %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_unban_data(@user) } + = s_('AdminUsers|Unban user') - else .gl-card.border-info.gl-mb-5 .gl-card-header.gl-bg-blue-500.gl-text-white - This user is blocked + = _('This user is blocked') .gl-card-body - %p A blocked user cannot: + %p= _('A blocked user cannot:') %ul - %li Log in - %li Access Git repositories + %li= _('Log in') + %li= _('Access Git repositories') %br %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_unblock_data(@user) } = s_('AdminUsers|Unblock user') - elsif !@user.internal? = render 'admin/users/block_user', user: @user + = render 'admin/users/ban_user', user: @user - if @user.access_locked? .card.border-info.gl-mb-5 .card-header.bg-info.text-white - This account has been locked + = _('This account has been locked') .card-body - %p This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account. + %p= _('This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account.') %br - = link_to 'Unlock user', unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' } + = link_to _('Unlock user'), unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: _('Are you sure?') } - if !@user.blocked_pending_approval? .gl-card.border-danger.gl-mb-5 .gl-card-header.bg-danger.text-white = s_('AdminUsers|Delete user') .gl-card-body - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) - %p Deleting a user has the following effects: + %p= _('Deleting a user has the following effects:') = render 'users/deletion_guidance', user: @user %br %button.js-delete-user-modal-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete', @@ -213,13 +231,13 @@ - else - if @user.solo_owned_groups.present? %p - This user is currently an owner in these groups: + = _('This user is currently an owner in these groups:') %strong= @user.solo_owned_groups.map(&:name).join(', ') %p - You must transfer ownership or delete these groups before you can delete this user. + = _('You must transfer ownership or delete these groups before you can delete this user.') - else %p - You don't have access to delete this user. + = _("You don't have access to delete this user.") .gl-card.border-danger .gl-card-header.bg-danger.text-white @@ -227,13 +245,8 @@ .gl-card-body - if can?(current_user, :destroy_user, @user) %p - This option deletes the user and any contributions that - would usually be moved to the - = succeed "." do - = link_to "system ghost user", help_page_path("user/profile/account/delete_account") - As well as the user's personal projects, groups owned solely by - the user, and projects in them, will also be removed. Commits - to other projects are unaffected. + - link_to_ghost_user = link_to(_("system ghost user"), help_page_path("user/profile/account/delete_account")) + = _("This option deletes the user and any contributions that would usually be moved to the %{link_to_ghost_user}. As well as the user's personal projects, groups owned solely by the user, and projects in them, will also be removed. Commits to other projects are unaffected.").html_safe % { link_to_ghost_user: link_to_ghost_user } %br %button.js-delete-user-modal-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions', delete_user_url: admin_user_path(@user, hard_delete: true), @@ -242,6 +255,6 @@ = s_('AdminUsers|Delete user and contributions') - else %p - You don't have access to delete this user. + = _("You don't have access to delete this user.") = render partial: 'admin/users/modals' diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml index 4a84745cf98..6d902132c73 100644 --- a/app/views/clusters/clusters/_banner.html.haml +++ b/app/views/clusters/clusters/_banner.html.haml @@ -3,7 +3,7 @@ %p.js-error-reason .hidden.js-cluster-creating.bs-callout.bs-callout-info{ role: 'alert' } - %span.spinner.spinner-dark.spinner-sm{ 'aria-label': 'Loading' } + %span.gl-spinner.gl-spinner-dark{ 'aria-label': 'Loading' } %span.gl-ml-2= s_('ClusterIntegration|Kubernetes cluster is being created...') .hidden.row.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' } diff --git a/app/views/clusters/clusters/_integrations.html.haml b/app/views/clusters/clusters/_integrations.html.haml index d718e3ecb26..96219fa9de5 100644 --- a/app/views/clusters/clusters/_integrations.html.haml +++ b/app/views/clusters/clusters/_integrations.html.haml @@ -1,19 +1,29 @@ .settings.expanded.border-0.m-0 %p - = s_('ClusterIntegration|Integrations enable you to integrate your cluster as part of your GitLab workflow.') + = s_('ClusterIntegration|Integrations allow you to use applications installed in your cluster as part of your GitLab workflow.') = link_to _('Learn more'), help_page_path('user/clusters/integrations.md'), target: '_blank' - .settings-content#advanced-settings-section + .settings-content#integrations-settings-section - if can?(current_user, :admin_cluster, @cluster) .sub-section.form-group - = form_for @prometheus_integration, url: @cluster.integrations_path, as: :integration, method: :post, html: { class: 'js-cluster-integrations-form' } do |form| - = form.hidden_field :application_type - .form-group + = form_for @prometheus_integration, as: :integration, namespace: :prometheus, url: @cluster.integrations_path, method: :post, html: { class: 'js-cluster-integrations-form' } do |prometheus_form| + = prometheus_form.hidden_field :application_type + .form-group.gl-form-group .gl-form-checkbox.custom-control.custom-checkbox - = form.check_box :enabled, { class: 'custom-control-input'} - = form.label :enabled, s_('ClusterIntegration|Enable Prometheus integration'), class: 'custom-control-label' - .gl-form-group + = prometheus_form.check_box :enabled, class: 'custom-control-input' + = prometheus_form.label :enabled, s_('ClusterIntegration|Enable Prometheus integration'), class: 'custom-control-label' .form-text.text-gl-muted - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("user/clusters/integrations", anchor: "prometheus-cluster-integration") } - - link_end = '</a>'.html_safe - = html_escape(s_('ClusterIntegration|Before you enable this integration, follow the %{link_start}documented process%{link_end}.')) % { link_start: link_start, link_end: link_end } - = form.submit _('Save changes'), class: 'btn gl-button btn-success' + = s_('ClusterIntegration|Allows GitLab to query a specifically configured in-cluster Prometheus for metrics.') + = link_to _('More information.'), help_page_path("user/clusters/integrations", anchor: "prometheus-cluster-integration"), target: '_blank' + = prometheus_form.submit _('Save changes'), class: 'btn gl-button btn-success' + + .sub-section.form-group + = form_for @elastic_stack_integration, as: :integration, namespace: :elastic_stack, url: @cluster.integrations_path, method: :post, html: { class: 'js-cluster-integrations-form' } do |elastic_stack_form| + = elastic_stack_form.hidden_field :application_type + .form-group.gl-form-group + .gl-form-checkbox.custom-control.custom-checkbox + = elastic_stack_form.check_box :enabled, class: 'custom-control-input' + = elastic_stack_form.label :enabled, s_('ClusterIntegration|Enable Elastic Stack integration'), class: 'custom-control-label' + .form-text.text-gl-muted + = s_('ClusterIntegration|Allows GitLab to query a specifically configured in-cluster Elasticsearch for pod logs.') + = link_to _('More information.'), help_page_path("user/clusters/integrations", anchor: "elastic-stack-cluster-integration"), target: '_blank' + = elastic_stack_form.submit _('Save changes'), class: 'btn gl-button btn-success' diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 01ba7c06154..001ca80dbd6 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -28,13 +28,13 @@ pre_installed_knative: @cluster.knative_pre_installed? ? 'true': 'false', help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), helm_help_path: help_page_path('user/clusters/applications.md', anchor: 'helm'), - ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'), + ingress_help_path: help_page_path('user/clusters/applications.md', anchor: 'determining-the-external-endpoint-automatically'), ingress_dns_help_path: help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint'), ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'), - environments_help_path: help_page_path('ci/environments/index.md', anchor: 'defining-environments'), + environments_help_path: help_page_path('ci/environments/index.md', anchor: 'create-a-static-environment'), clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'), deploy_boards_help_path: help_page_path('user/project/deploy_boards.md', anchor: 'enabling-deploy-boards'), - cloud_run_help_path: help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'cloud-run-for-anthos'), + cloud_run_help_path: help_page_path('user/project/clusters/add_gke_clusters.md', anchor: 'cloud-run-for-anthos'), manage_prometheus_path: manage_prometheus_path, cluster_id: @cluster.id, cilium_help_path: help_page_path('user/clusters/applications.md', anchor: 'install-cilium-using-gitlab-cicd')} } diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index d617ee0e4cc..ec07c636b79 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -6,4 +6,4 @@ .content_list .loading - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml index 2f9dbf87d95..d5cd4b66e2b 100644 --- a/app/views/dashboard/groups/_groups.html.haml +++ b/app/views/dashboard/groups/_groups.html.haml @@ -1,4 +1,4 @@ .js-groups-list-holder #js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } .loading-container.text-center.prepend-top-20 - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index f2f8afb636d..e7d8171d276 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -1,61 +1,61 @@ -%li{ class: "todo todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data: { url: todo_target_path(todo) } } - .todo-avatar - = author_avatar(todo, size: 40) - - .todo-item.todo-block.align-self-center{ data: { qa_selector: "todo_item_container" } } - .todo-title - - if todo_author_display?(todo) - = todo_target_state_pill(todo) - - %span.title-item.author-name.bold - - if todo.author - = link_to_author(todo, self_added: todo.self_added?) +%li.todo{ class: "todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data: { url: todo_target_path(todo) } } + .gl-display-flex.gl-flex-direction-row + .todo-avatar.gl-display-none.gl-sm-display-inline-block + = author_avatar(todo, size: 40) + + .todo-item.gl-w-full.gl-align-self-center{ data: { qa_selector: "todo_item_container" } } + .todo-title.gl-mb-3.gl-md-mb-0 + - if todo_author_display?(todo) + = todo_target_state_pill(todo) + + %span.title-item.author-name.bold + - if todo.author + = link_to_author(todo, self_added: todo.self_added?) + - else + (removed) + + %span.title-item.action-name{ data: { qa_selector: "todo_action_name_content" } } + = todo_action_name(todo) + + %span.title-item.todo-label.todo-target-link + - if todo.target + = todo_target_link(todo) - else - (removed) - - %span.title-item.action-name{ data: { qa_selector: "todo_action_name_content" } } - = todo_action_name(todo) - - %span.title-item.todo-label.todo-target-link - - if todo.target - = todo_target_link(todo) - - else - = _("(removed)") - - %span.title-item.todo-target-title{ data: { qa_selector: "todo_target_title_content" } } - = todo_target_title(todo) - - %span.title-item.todo-project.todo-label - at - = todo_parent_path(todo) - - - if todo.self_assigned? - %span.title-item.action-name - = todo_self_addressing(todo) - - %span.title-item - · - - %span.title-item.todo-timestamp - #{time_ago_with_tooltip(todo.created_at)} - = todo_due_date(todo) - - - if todo.note.present? - .todo-body - .todo-note.break-word - .md - = first_line_in_markdown(todo, :body, 150, project: todo.project) - - - if todo.pending? - .todo-actions - = link_to dashboard_todo_path(todo), method: :delete, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-done-todo', data: { href: dashboard_todo_path(todo) } do - Done - %span.spinner.ml-1 - = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do - Undo - %span.spinner.ml-1 - - else - .todo-actions - = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-add-todo', data: { href: restore_dashboard_todo_path(todo) } do - Add a to do - %span.spinner.ml-1 + = _("(removed)") + + %span.title-item.todo-target-title{ data: { qa_selector: "todo_target_title_content" } } + = todo_target_title(todo) + + %span.title-item.todo-project.todo-label + at + = todo_parent_path(todo) + + - if todo.self_assigned? + %span.title-item.action-name + = todo_self_addressing(todo) + + %span.title-item + · + + %span.title-item.todo-timestamp + #{time_ago_with_tooltip(todo.created_at)} + = todo_due_date(todo) + + - if todo.note.present? + .todo-body + .todo-note.break-word + .md + = first_line_in_markdown(todo, :body, 150, project: todo.project) + + .todo-actions.gl-ml-3 + - if todo.pending? + = link_to dashboard_todo_path(todo), method: :delete, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-done-todo', data: { href: dashboard_todo_path(todo) } do + Done + %span.gl-spinner.ml-1 + = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-undo-todo hidden', data: { href: restore_dashboard_todo_path(todo) } do + Undo + %span.gl-spinner.ml-1 + - else + = link_to restore_dashboard_todo_path(todo), method: :patch, class: 'gl-button btn btn-default btn-loading d-flex align-items-center js-add-todo', data: { href: restore_dashboard_todo_path(todo) } do + Add a to do + %span.gl-spinner.ml-1 diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index a0016417f0c..52e41946ed1 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -15,13 +15,13 @@ = link_to todos_filter_path(state: 'pending') do %span To Do - %span.badge.badge-pill + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm = number_with_delimiter(todos_pending_count) %li.todos-done{ class: active_when(params[:state] == 'done') }> = link_to todos_filter_path(state: 'done') do %span Done - %span.badge.badge-pill + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm = number_with_delimiter(todos_done_count) .nav-controls @@ -29,41 +29,41 @@ .gl-mr-3 = link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'gl-button btn btn-default btn-loading align-items-center js-todos-mark-all', method: :delete, data: { href: destroy_all_dashboard_todos_path(todos_filter_params) } do Mark all as done - %span.spinner.ml-1 + %span.gl-spinner.ml-1 = link_to bulk_restore_dashboard_todos_path, class: 'gl-button btn btn-default btn-loading align-items-center js-todos-undo-all hidden', method: :patch , data: { href: bulk_restore_dashboard_todos_path(todos_filter_params) } do Undo mark all as done - %span.spinner.ml-1 + %span.gl-spinner.ml-1 .todos-filters .issues-details-filters.row-content-block.second-block - = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form d-sm-flex' do - .filter-categories.flex-fill - .filter-item.inline + = form_tag todos_filter_path(without: [:project_id, :author_id, :type, :action_id]), method: :get, class: 'filter-form gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row' do + .filter-categories.gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row.gl-flex-fill-1.gl-flex-wrap.gl-mx-n2 + .filter-item.gl-m-2 - if params[:group_id].present? = hidden_field_tag(:group_id, params[:group_id]) - = dropdown_tag(group_dropdown_label(params[:group_id], 'Group'), options: { toggle_class: 'js-group-search js-filter-submit', title: 'Filter by group', filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit', + = dropdown_tag(group_dropdown_label(params[:group_id], 'Group'), options: { toggle_class: 'js-group-search js-filter-submit gl-xs-w-full!', title: 'Filter by group', filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit', placeholder: 'Search groups', data: { default_label: 'Group', display: 'static' } }) - .filter-item.inline + .filter-item.gl-m-2 - if params[:project_id].present? = hidden_field_tag(:project_id, params[:project_id]) - = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', + = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit gl-xs-w-full!', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', placeholder: 'Search projects', data: { default_label: 'Project', display: 'static' } }) - .filter-item.inline + .filter-item.gl-m-2 - if params[:author_id].present? = hidden_field_tag(:author_id, params[:author_id]) - = dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit', + = dropdown_tag(user_dropdown_label(params[:author_id], 'Author'), options: { toggle_class: 'js-user-search js-filter-submit js-author-search gl-xs-w-full!', title: 'Filter by author', filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit', placeholder: 'Search authors', data: { any_user: 'Any Author', first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: 'Author', todo_filter: true, todo_state_filter: params[:state] || 'pending' } }) - .filter-item.inline + .filter-item.gl-m-2 - if params[:type].present? = hidden_field_tag(:type, params[:type]) - = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit', + = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit gl-xs-w-full!', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit', data: { data: todo_types_options, default_label: 'Type' } }) - .filter-item.inline.actions-filter + .filter-item.actions-filter.gl-m-2 - if params[:action_id].present? = hidden_field_tag(:action_id, params[:action_id]) - = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', + = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit gl-xs-w-full!', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', data: { data: todo_actions_options, default_label: 'Action' } }) - .filter-item.sort-filter + .filter-item.sort-filter.gl-mt-3.gl-sm-mt-0.gl-mb-0.gl-sm-mb-0 .dropdown %button.dropdown-menu-toggle.dropdown-menu-toggle-sort{ type: 'button', class: 'gl-xs-w-full!', 'data-toggle' => 'dropdown' } %span.light @@ -81,40 +81,45 @@ = link_to todos_filter_path(sort: sort_value_oldest_created) do = sort_title_oldest_created -.todos-list-container.js-todos-all +.row.js-todos-all - if @todos.any? - .js-todos-list-container{ data: { qa_selector: "todos_list_container" } } + .col.js-todos-list-container{ data: { qa_selector: "todos_list_container" } } .js-todos-options{ data: { per_page: @todos.limit_value, current_page: @todos.current_page, total_pages: @todos.total_pages } } %ul.content-list.todos-list = render @todos = paginate @todos, theme: "gitlab" - .js-nothing-here-container.todos-all-done.hidden.svg-content - = image_tag 'illustrations/todos_all_done.svg' - %h4.text-center - You're all done! + .js-nothing-here-container.empty-state.hidden + .svg-content + = image_tag 'illustrations/todos_all_done.svg' + .text-content + %h4.text-center + You're all done! - elsif current_user.todos.any? - .todos-all-done + .col.todos-all-done.empty-state .svg-content.svg-250 = image_tag 'illustrations/todos_all_done.svg' - - if todos_filter_empty? - %h4.text-center - = Gitlab.config.gitlab.no_todos_messages.sample - %p - Are you looking for things to do? Take a look at - = succeed "," do - = link_to "open issues", issues_dashboard_path - contribute to - = link_to "a merge request\,", merge_requests_dashboard_path - or mention someone in a comment to automatically assign them a new to-do item. - - else - %h4.text-center - Nothing is on your to-do list. Nice work! + .text-content + - if todos_filter_empty? + %h4.text-center + = Gitlab.config.gitlab.no_todos_messages.sample + %p + Are you looking for things to do? Take a look at + = succeed "," do + %strong + = link_to "open issues", issues_dashboard_path + contribute to + %strong + = link_to "a merge request\,", merge_requests_dashboard_path + or mention someone in a comment to automatically assign them a new to-do item. + - else + %h4.text-center + Nothing is on your to-do list. Nice work! - else - .todos-empty - .todos-empty-hero.svg-content + .col.empty-state + .svg-content = image_tag 'illustrations/todos_empty.svg' - .todos-empty-content.gl-mx-5 - %h4 + .text-content + %h4.text-center Your To-Do List shows what to work on next %p When an issue or merge request is assigned to you, or when you receive a diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml index 024ccaddaa1..51354618aa4 100644 --- a/app/views/devise/confirmations/new.html.haml +++ b/app/views/devise/confirmations/new.html.haml @@ -6,9 +6,9 @@ = render "devise/shared/error_messages", resource: resource .form-group = f.label :email - = f.email_field :email, class: "form-control gl-form-input", required: true, title: 'Please provide a valid email address.', value: nil + = f.email_field :email, class: "form-control gl-form-input", required: true, title: _('Please provide a valid email address.'), value: nil .clearfix - = f.submit "Resend", class: 'gl-button btn btn-confirm' + = f.submit _("Resend"), class: 'gl-button btn btn-confirm' .clearfix.prepend-top-20 = render 'devise/shared/sign_in_link' diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml index f14d50eaf71..97fdf0249da 100644 --- a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml +++ b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml @@ -1,8 +1,8 @@ #content - = email_default_heading("#{sanitize_name(@resource.user.name)}, confirm your email address now!") - %p Click the link below to confirm your email address (#{@resource.email}) + = email_default_heading(_("%{name}, confirm your email address now!") % { name: sanitize_name(@resource.user.name) }) + %p= _('Click the link below to confirm your email address (%{email})') % { email: @resource.email } #cta - = link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token) + = link_to _('Confirm your email address'), confirmation_url(@resource, confirmation_token: @token) %p - If this email was added in error, you can remove it here: - = link_to "Emails", profile_emails_url + = _('If this email was added in error, you can remove it here:') + = link_to _("Emails"), profile_emails_url diff --git a/app/views/devise/mailer/reset_password_instructions.html.haml b/app/views/devise/mailer/reset_password_instructions.html.haml index 47e192afa52..717f51b662f 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.haml +++ b/app/views/devise/mailer/reset_password_instructions.html.haml @@ -1,10 +1,9 @@ -= email_default_heading("Hello, #{@resource.name}!") += email_default_heading(_("Hello, %{name}!") % { name: @resource.name }) %p - Someone, hopefully you, has requested to reset the password for your - GitLab account on #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)}. + = _('Someone, hopefully you, has requested to reset the password for your GitLab account on %{link_to_gitlab}.').html_safe % { link_to_gitlab: Gitlab.config.gitlab.url } %p - If you did not perform this request, you can safely ignore this email. + = _('If you did not perform this request, you can safely ignore this email.') %p - Otherwise, click the link below to complete the process. + = _('Otherwise, click the link below to complete the process.') #cta - = link_to('Reset password', edit_password_url(@resource, reset_password_token: @token)) + = link_to(_('Reset password'), edit_password_url(@resource, reset_password_token: @token)) diff --git a/app/views/devise/mailer/reset_password_instructions.text.erb b/app/views/devise/mailer/reset_password_instructions.text.erb index 116313ee11c..c8d86fe998e 100644 --- a/app/views/devise/mailer/reset_password_instructions.text.erb +++ b/app/views/devise/mailer/reset_password_instructions.text.erb @@ -1,10 +1,9 @@ -Hello, <%= @resource.name %>! +<%= _("Hello, %{name}!") % { name: @resource.name } %> -Someone, hopefully you, has requested to reset the password for your GitLab -account on <%= Gitlab.config.gitlab.url %> +<%= _("Someone, hopefully you, has requested to reset the password for your GitLab account on %{link_to_gitlab}.") % { link_to_gitlab: Gitlab.config.gitlab.url } %> -If you did not perform this request, you can safely ignore this email. +<%= _("If you did not perform this request, you can safely ignore this email.") %> -Otherwise, click the link below to complete the process: +<%= _("Otherwise, click the link below to complete the process:") %> <%= edit_password_url(@resource, reset_password_token: @token) %> diff --git a/app/views/devise/mailer/unlock_instructions.text.erb b/app/views/devise/mailer/unlock_instructions.text.erb index 8d4abbf3500..9b1e2166cee 100644 --- a/app/views/devise/mailer/unlock_instructions.text.erb +++ b/app/views/devise/mailer/unlock_instructions.text.erb @@ -1,7 +1,5 @@ -Hello, <%= @resource.name %>! +<%= _('Hello, %{name}!') % { name: @resource.name } %> -Your GitLab account has been locked due to an excessive amount of unsuccessful -sign in attempts. Your account will automatically unlock in <%= distance_of_time_in_words(Devise.unlock_in) %> -or you may click the link below to unlock now. +<%= _("Your GitLab account has been locked due to an excessive amount of unsuccessful sign in attempts. Your account will automatically unlock in %{duration} or you may click the link below to unlock now.") % { duration: distance_of_time_in_words(Devise.unlock_in) } %> <%= unlock_url(@resource, unlock_token: @token) %> diff --git a/app/views/devise/shared/_links.erb b/app/views/devise/shared/_links.erb index cb934434c28..f0215f5ea42 100644 --- a/app/views/devise/shared/_links.erb +++ b/app/views/devise/shared/_links.erb @@ -1,19 +1,19 @@ <%- if controller_name != 'sessions' %> - <%= link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: "btn" %><br /> + <%= link_to _("Sign in"), new_session_path(:user, redirect_to_referer: 'yes'), class: "btn" %><br /> <% end -%> <%- if devise_mapping.registerable? && controller_name != 'registrations' && allow_signup? %> - <%= link_to "Sign up", new_registration_path(:user) %><br /> + <%= link_to _("Sign up"), new_registration_path(:user) %><br /> <% end -%> <%- if devise_mapping.recoverable? && controller_name != 'passwords' %> -<%= link_to "Forgot your password?", new_password_path(:user), class: "btn" %><br /> +<%= link_to _("Forgot your password?"), new_password_path(:user), class: "btn" %><br /> <% end -%> <%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> - <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(:user) %><br /> + <%= link_to _("Didn't receive confirmation instructions?"), new_confirmation_path(:user) %><br /> <% end -%> <%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> - <%= link_to "Didn't receive unlock instructions?", new_unlock_path(:user) %><br /> + <%= link_to _("Didn't receive unlock instructions?"), new_unlock_path(:user) %><br /> <% end -%> diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 2fc89f18de6..56f74916d8f 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -37,6 +37,6 @@ = recaptcha_tags .submit-container = f.submit button_text, class: 'btn gl-button btn-confirm', data: { qa_selector: 'new_user_register_button' } - = render 'devise/shared/terms_of_service_notice' + = render 'devise/shared/terms_of_service_notice', button_text: button_text - if show_omniauth_providers && omniauth_providers_placement == :bottom = render 'devise/shared/signup_omniauth_providers' diff --git a/app/views/devise/shared/_signup_omniauth_providers_top.haml b/app/views/devise/shared/_signup_omniauth_providers_top.haml index a2cf5165c1f..9a2629443ed 100644 --- a/app/views/devise/shared/_signup_omniauth_providers_top.haml +++ b/app/views/devise/shared/_signup_omniauth_providers_top.haml @@ -1,3 +1,3 @@ -= render 'devise/shared/signup_omniauth_provider_list', providers: trial_enabled_button_based_providers += render 'devise/shared/signup_omniauth_provider_list', providers: popular_enabled_button_based_providers .omniauth-divider.d-flex.align-items-center.text-center = _("or") diff --git a/app/views/devise/shared/_terms_of_service_notice.html.haml b/app/views/devise/shared/_terms_of_service_notice.html.haml index 46b043b2831..75d567a03fd 100644 --- a/app/views/devise/shared/_terms_of_service_notice.html.haml +++ b/app/views/devise/shared/_terms_of_service_notice.html.haml @@ -1,5 +1,9 @@ -- company_name = Gitlab.com? ? 'GitLab' : '' +- return unless Gitlab::CurrentSettings.current_application_settings.enforce_terms? -- if Gitlab::CurrentSettings.current_application_settings.enforce_terms? - %p.gl-text-gray-500.gl-mt-5.gl-mb-0 - = html_escape(_("By clicking Register, I agree that I have read and accepted the %{company_name} %{linkStart}Terms of Use and Privacy Policy%{linkEnd}")) % { linkStart: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, linkEnd: '</a>'.html_safe, company_name: company_name } +%p.gl-text-gray-500.gl-mt-5.gl-mb-0 + - if Gitlab.dev_env_or_com? + = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the GitLab %{link_start}Terms of Use and Privacy Policy%{link_end}")) % { button_text: button_text, + link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } + - else + = html_escape(s_("SignUp|By clicking %{button_text}, I agree that I have read and accepted the %{link_start}Terms of Use and Privacy Policy%{link_end}")) % { button_text: button_text, + link_start: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, link_end: '</a>'.html_safe } diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index 5e93b1d89eb..f0e7a96f69f 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -17,10 +17,8 @@ = _("An application called %{link_to_client} is requesting access to your GitLab account.").html_safe % { link_to_client: link_to_client } - auth_app_owner = @pre_auth.client.application.owner - - if auth_app_owner - - link_to_owner = link_to(auth_app_owner.name, user_path(auth_app_owner)) - = _("This application was created by %{link_to_owner}.").html_safe % { link_to_owner: link_to_owner } + = auth_app_owner_text(auth_app_owner) = _("Please note that this application is not provided by GitLab and you should verify its authenticity before allowing access.") - if @pre_auth.scopes %p diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml index a3249275d5e..0358fc524d3 100644 --- a/app/views/explore/groups/_groups.html.haml +++ b/app/views/explore/groups/_groups.html.haml @@ -1,4 +1,4 @@ .js-groups-list-holder #js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } } .loading-container.text-center.prepend-top-20 - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index b1a40bfc96b..1695d3b5539 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -6,4 +6,4 @@ .content_list .loading - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/groups/_archived_projects.html.haml b/app/views/groups/_archived_projects.html.haml index 48e9f630050..959c26acae0 100644 --- a/app/views/groups/_archived_projects.html.haml +++ b/app/views/groups/_archived_projects.html.haml @@ -5,4 +5,4 @@ %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } .js-groups-list-holder .loading-container.text-center.prepend-top-20 - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml index 2c9d9349f14..2b9277c67e9 100644 --- a/app/views/groups/_import_group_from_another_instance_panel.html.haml +++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml @@ -18,7 +18,8 @@ = f.text_field :bulk_import_gitlab_url, placeholder: 'https://gitlab.example.com', class: 'gl-form-input col-xs-12 col-sm-8', required: true, title: s_('GroupsNew|Please fill in GitLab source URL.'), - id: 'import_gitlab_url' + id: 'import_gitlab_url', + data: { qa_selector: 'import_gitlab_url' } .form-group.gl-display-flex.gl-flex-direction-column = f.label :bulk_import_gitlab_access_token, s_('GroupsNew|Personal access token'), for: 'import_gitlab_token' .gl-font-weight-normal @@ -27,6 +28,7 @@ = f.text_field :bulk_import_gitlab_access_token, placeholder: s_('GroupsNew|e.g. h8d3f016698e...'), class: 'gl-form-input gl-mt-3 col-xs-12 col-sm-8', required: true, title: s_('GroupsNew|Please fill in your personal access token.'), - id: 'import_gitlab_token' + id: 'import_gitlab_token', + data: { qa_selector: 'import_gitlab_token' } .gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5 - = f.submit s_('GroupsNew|Connect instance'), class: 'btn gl-button btn-confirm' + = f.submit s_('GroupsNew|Connect instance'), class: 'btn gl-button btn-confirm', data: { qa_selector: 'connect_instance_button' } diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml index ba6dfcb70ff..69ed94e99cc 100644 --- a/app/views/groups/_invite_members_modal.html.haml +++ b/app/views/groups/_invite_members_modal.html.haml @@ -1,4 +1,4 @@ -- if can_invite_members_for_group?(group) +- if can?(current_user, :admin_group_member, group) .js-invite-members-modal{ data: { id: group.id, name: group.name, is_project: 'false', diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml index 2769b69add3..bfd056ccdd2 100644 --- a/app/views/groups/_shared_projects.html.haml +++ b/app/views/groups/_shared_projects.html.haml @@ -5,4 +5,4 @@ %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } .js-groups-list-holder .loading-container.text-center.prepend-top-20 - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml index d9ab828a83b..651d182b9cc 100644 --- a/app/views/groups/_subgroups_and_projects.html.haml +++ b/app/views/groups/_subgroups_and_projects.html.haml @@ -5,4 +5,4 @@ %section{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } .js-groups-list-holder{ data: { show_schema_markup: 'true'} } .loading-container.text-center.prepend-top-20 - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/groups/boards/show.html.haml b/app/views/groups/boards/show.html.haml index 92838fa4b11..dbbf78eed00 100644 --- a/app/views/groups/boards/show.html.haml +++ b/app/views/groups/boards/show.html.haml @@ -1 +1,3 @@ += render 'shared/alerts/positioning_disabled' + = render "shared/boards/show", board: @board, group: true diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 106a7832cc7..45488791272 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -1,27 +1,26 @@ - add_page_specific_style 'page_bundles/members' - page_title _('Group members') -- can_manage_members = can?(current_user, :admin_group_member, @group) -- show_invited_members = can_manage_members && @invited_members.exists? -- show_access_requests = can_manage_members && @requesters.exists? +- show_invited_members = can_manage_members? && @invited_members.load.any? +- show_access_requests = can_manage_members? && @requesters.load.any? - invited_active = params[:search_invited].present? || params[:invited_members_page].present? .js-remove-member-modal .row.gl-mt-3 .col-lg-12 .gl-display-flex.gl-flex-wrap - - if can_manage_members + - if can_manage_members? .gl-w-half.gl-xs-w-full %h4 = _('Group members') %p = html_escape(_('You can invite a new member to %{strong_start}%{group_name}%{strong_end}.')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - - if can_invite_members_for_group?(@group) + - if Feature.enabled?(:invite_members_group_modal, @group) .gl-w-half.gl-xs-w-full .gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-mb-3 .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } } .js-invite-members-trigger{ data: { variant: 'success', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } } = render 'groups/invite_members_modal', group: @group - - if can_manage_members && !can_invite_members_for_group?(@group) + - if can_manage_members? && Feature.disabled?(:invite_members_group_modal, @group) %hr.gl-mt-4 %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } @@ -42,7 +41,7 @@ %span = _('Members') %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @members.total_count - - if @group.shared_with_group_links.any? + - if @group.shared_with_group_links.present? %li.nav-item = link_to '#tab-groups', class: ['nav-link'] , data: { toggle: 'tab', qa_selector: 'groups_list_tab' } do %span @@ -62,23 +61,21 @@ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @requesters.count .tab-content #tab-members.tab-pane{ class: ('active' unless invited_active) } - .js-group-members-list{ data: group_members_list_data_attributes(@group, @members) } + .js-group-members-list{ data: { members_data: group_members_list_data_json(@group, @members, { param_name: :page, params: { invited_members_page: nil, search_invited: nil } }) } } .loading - .spinner.spinner-md - = paginate @members, theme: 'gitlab', params: { invited_members_page: nil, search_invited: nil } - - if @group.shared_with_group_links.any? + .gl-spinner.gl-spinner-md + - if @group.shared_with_group_links.present? #tab-groups.tab-pane - .js-group-group-links-list{ data: group_group_links_list_data_attributes(@group) } + .js-group-group-links-list{ data: { members_data: group_group_links_list_data_json(@group) } } .loading - .spinner.spinner-md + .gl-spinner.gl-spinner-md - if show_invited_members #tab-invited-members.tab-pane{ class: ('active' if invited_active) } - .js-group-invited-members-list{ data: group_members_list_data_attributes(@group, @invited_members) } + .js-group-invited-members-list{ data: { members_data: group_members_list_data_json(@group, @invited_members, { param_name: :invited_members_page, params: { page: nil } }) } } .loading - .spinner.spinner-md - = paginate @invited_members, param_name: 'invited_members_page', theme: 'gitlab', params: { page: nil } + .gl-spinner.gl-spinner-md - if show_access_requests #tab-access-requests.tab-pane - .js-group-access-requests-list{ data: group_members_list_data_attributes(@group, @requesters) } + .js-group-access-requests-list{ data: { members_data: group_members_list_data_json(@group, @requesters) } } .loading - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/groups/imports/show.html.haml b/app/views/groups/imports/show.html.haml index ac8ca8797fe..79cac364016 100644 --- a/app/views/groups/imports/show.html.haml +++ b/app/views/groups/imports/show.html.haml @@ -4,7 +4,7 @@ .save-group-loader .center %h2 - %i.loading.spinner.spinner-sm + %i.loading.gl-spinner = page_title %p = s_('GroupImport|Please wait while we import the group for you. Refresh at will.') diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index ae4b0807fc5..fdd6962eb21 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -1,4 +1,4 @@ -- @can_bulk_update = can?(current_user, :admin_issue, @group) && @group.feature_available?(:group_bulk_edit) +- @can_bulk_update = can?(current_user, :admin_issue, @group) && @group.licensed_feature_available?(:group_bulk_edit) - page_title _("Issues") - add_page_specific_style 'page_bundles/issues_list' diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index 15864e18f7c..33f836c2de0 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -1,4 +1,4 @@ -- @can_bulk_update = can?(current_user, :admin_merge_request, @group) && @group.feature_available?(:group_bulk_edit) +- @can_bulk_update = can?(current_user, :admin_merge_request, @group) && @group.licensed_feature_available?(:group_bulk_edit) - page_title _("Merge requests") diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml index d4d8a7a57ef..259e96901fd 100644 --- a/app/views/groups/milestones/_form.html.haml +++ b/app/views/groups/milestones/_form.html.haml @@ -12,7 +12,11 @@ = f.label :description, _("Description") .col-sm-10 = render layout: 'shared/md_preview', locals: { url: group_preview_markdown_path } do - = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', qa_selector: 'milestone_description_field', placeholder: _('Write milestone description...'), supports_autocomplete: false + = render 'shared/zen', f: f, attr: :description, + classes: 'note-textarea', + qa_selector: 'milestone_description_field', + supports_autocomplete: true, + placeholder: _('Write milestone description...') .clearfix .error-alert = render "shared/milestones/form_dates", f: f diff --git a/app/views/groups/milestones/_header_title.html.haml b/app/views/groups/milestones/_header_title.html.haml index 24eb39b8e2f..f222dba1f90 100644 --- a/app/views/groups/milestones/_header_title.html.haml +++ b/app/views/groups/milestones/_header_title.html.haml @@ -1,2 +1,2 @@ - breadcrumb_title @milestone.title -- add_to_breadcrumbs "Milestones", group_milestones_path(@group) +- add_to_breadcrumbs _("Milestones"), group_milestones_path(@group) diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml index 2c93b0e4efd..0d4565706d4 100644 --- a/app/views/groups/milestones/new.html.haml +++ b/app/views/groups/milestones/new.html.haml @@ -3,7 +3,7 @@ - page_title _("Milestones"), @milestone.name, _("Milestones") %h3.page-title - New Milestone + = _("New Milestone") %hr diff --git a/app/views/groups/runners/edit.html.haml b/app/views/groups/runners/edit.html.haml index 3794c345aa6..a0d7b8acb47 100644 --- a/app/views/groups/runners/edit.html.haml +++ b/app/views/groups/runners/edit.html.haml @@ -1,4 +1,7 @@ -- page_title _('Edit'), "#{@runner.description} ##{@runner.id}", _('Runners') +- breadcrumb_title _('Edit') +- page_title _('Edit'), "##{@runner.id} (#{@runner.short_sha})" +- add_to_breadcrumbs _('CI/CD Settings'), group_settings_ci_cd_path(@group) +- add_to_breadcrumbs "#{@runner.short_sha}", group_runner_path(@group, @runner) %h2.page-title = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id }) diff --git a/app/views/groups/runners/show.html.haml b/app/views/groups/runners/show.html.haml new file mode 100644 index 00000000000..5cf83e8ccfd --- /dev/null +++ b/app/views/groups/runners/show.html.haml @@ -0,0 +1,3 @@ +- add_to_breadcrumbs _('CI/CD Settings'), group_settings_ci_cd_path(@group) + += render 'shared/runners/runner_details', runner: @runner diff --git a/app/views/groups/settings/_lfs.html.haml b/app/views/groups/settings/_lfs.html.haml index 77c84862316..b16c9faafa4 100644 --- a/app/views/groups/settings/_lfs.html.haml +++ b/app/views/groups/settings/_lfs.html.haml @@ -6,10 +6,8 @@ %p= s_('Check the %{docs_link_start}documentation%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } .form-group.gl-mb-3 - .form-check - = f.check_box :lfs_enabled, checked: @group.lfs_enabled?, class: 'form-check-input', data: { qa_selector: 'lfs_checkbox' } - = f.label :lfs_enabled, class: 'form-check-label' do - %span - = _('Allow projects within this group to use Git LFS') - %br/ - %span.text-muted= _('This setting can be overridden in each project.') + .gl-form-checkbox.custom-control.custom-checkbox + = f.check_box :lfs_enabled, checked: @group.lfs_enabled?, class: 'custom-control-input', data: { qa_selector: 'lfs_checkbox' } + = f.label :lfs_enabled, class: 'custom-control-label' do + = _('Allow projects within this group to use Git LFS') + %p.help-text= _('This setting can be overridden in each project.') diff --git a/app/views/groups/settings/_two_factor_auth.html.haml b/app/views/groups/settings/_two_factor_auth.html.haml index fac3df5237f..bd3b3283288 100644 --- a/app/views/groups/settings/_two_factor_auth.html.haml +++ b/app/views/groups/settings/_two_factor_auth.html.haml @@ -7,17 +7,17 @@ %p= s_('Check the %{docs_link_start}documentation%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe } .form-group - .form-check - = f.check_box :require_two_factor_authentication, class: 'form-check-input', data: { qa_selector: 'require_2fa_checkbox' } - = f.label :require_two_factor_authentication, class: 'form-check-label' do - %span= _('Require all users in this group to setup two-factor authentication') + .gl-form-checkbox.custom-control.custom-checkbox + = f.check_box :require_two_factor_authentication, class: 'custom-control-input', data: { qa_selector: 'require_2fa_checkbox' } + = f.label :require_two_factor_authentication, class: 'custom-control-label' do + = _('Require all users in this group to setup two-factor authentication') .form-group = f.label :two_factor_grace_period, _('Time before enforced'), class: 'label-bold' = f.text_field :two_factor_grace_period, class: 'form-control form-control-sm w-auto' .form-text.text-muted= _('Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication') - unless group.has_parent? .form-group - .form-check - = f.check_box :allow_mfa_for_subgroups, class: 'form-check-input', checked: group.namespace_settings&.allow_mfa_for_subgroups - = f.label :allow_mfa_for_subgroups, class: 'form-check-label' do + .gl-form-checkbox.custom-control.custom-checkbox + = f.check_box :allow_mfa_for_subgroups, class: 'custom-control-input', checked: group.namespace_settings&.allow_mfa_for_subgroups + = f.label :allow_mfa_for_subgroups, class: 'custom-control-label' do = _('Allow subgroups to set up their own two-factor authentication rules') diff --git a/app/views/groups/settings/packages_and_registries/index.html.haml b/app/views/groups/settings/packages_and_registries/show.html.haml index 1a12ad4902b..1a12ad4902b 100644 --- a/app/views/groups/settings/packages_and_registries/index.html.haml +++ b/app/views/groups/settings/packages_and_registries/show.html.haml diff --git a/app/views/groups/settings/repository/_initial_branch_name.html.haml b/app/views/groups/settings/repository/_initial_branch_name.html.haml index efe690a0c2d..23ac7d51e4f 100644 --- a/app/views/groups/settings/repository/_initial_branch_name.html.haml +++ b/app/views/groups/settings/repository/_initial_branch_name.html.haml @@ -9,12 +9,12 @@ .settings-content = form_for @group, url: group_path(@group, anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f| = form_errors(@group) - - fallback_branch_name = '<code>master</code>' + - fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value(object: @group)}</code>" %fieldset .form-group = f.label :default_branch_name, _('Default initial branch name'), class: 'label-light' - = f.text_field :default_branch_name, value: group.namespace_settings&.default_branch_name, placeholder: 'master', class: 'form-control' + = f.text_field :default_branch_name, value: group.namespace_settings&.default_branch_name, placeholder: Gitlab::DefaultBranch.value(object: @group), class: 'form-control' %span.form-text.text-muted = (_("Changes affect new repositories only. If not specified, either the configured application-wide default or Git's default name %{branch_name_default} will be used.") % { branch_name_default: fallback_branch_name }).html_safe diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index a1557cda071..9f7f0a08df5 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -12,6 +12,7 @@ is_dismissed_key: "invite_#{@group.id}_#{current_user.id}", track_label: 'invite_members_banner', invite_members_path: group_group_members_path(@group) } } + = render 'groups/invite_members_modal', group: @group = content_for :meta_tags do = auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity") diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml index 70ac532e69f..755c4151115 100644 --- a/app/views/ide/_show.html.haml +++ b/app/views/ide/_show.html.haml @@ -4,7 +4,10 @@ - add_page_specific_style 'page_bundles/build' - add_page_specific_style 'page_bundles/ide' +- content_for :prefetch_asset_tags do + - webpack_preload_asset_tag('monaco') + #ide.ide-loading{ data: ide_data } .text-center - .spinner.spinner-md + .gl-spinner.gl-spinner-md %h2.clgray= _('Loading the GitLab IDE...') diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml index 308065da90a..8a3fe1a816c 100644 --- a/app/views/import/bitbucket_server/new.html.haml +++ b/app/views/import/bitbucket_server/new.html.haml @@ -15,14 +15,14 @@ .form-group.row = label_tag :bitbucket_server_url, 'Bitbucket Server URL', class: 'col-form-label col-md-2' .col-md-4 - = text_field_tag :bitbucket_server_url, '', class: 'form-control gl-mr-3', placeholder: _('https://your-bitbucket-server'), size: 40 + = text_field_tag :bitbucket_server_url, '', class: 'form-control gl-form-input gl-mr-3', placeholder: _('https://your-bitbucket-server'), size: 40 .form-group.row = label_tag :bitbucket_server_url, 'Username', class: 'col-form-label col-md-2' .col-md-4 - = text_field_tag :bitbucket_server_username, '', class: 'form-control gl-mr-3', placeholder: _('username'), size: 40 + = text_field_tag :bitbucket_server_username, '', class: 'form-control gl-form-input gl-mr-3', placeholder: _('username'), size: 40 .form-group.row = label_tag :personal_access_token, 'Password/Personal Access Token', class: 'col-form-label col-md-2' .col-md-4 - = password_field_tag :personal_access_token, '', class: 'form-control gl-mr-3', placeholder: _('Personal Access Token'), size: 40 + = password_field_tag :personal_access_token, '', class: 'form-control gl-form-input gl-mr-3', placeholder: _('Personal Access Token'), size: 40 .form-actions = submit_tag _('List your Bitbucket Server repositories'), class: 'gl-button btn btn-confirm' diff --git a/app/views/import/bulk_imports/status.html.haml b/app/views/import/bulk_imports/status.html.haml index 917d88af75a..cd90c76ed10 100644 --- a/app/views/import/bulk_imports/status.html.haml +++ b/app/views/import/bulk_imports/status.html.haml @@ -2,9 +2,6 @@ - add_page_specific_style 'page_bundles/import' - breadcrumb_title _('Import groups') -%h1.gl-my-0.gl-py-4.gl-font-size-h1.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1 - = s_('BulkImport|Import groups from GitLab') - #import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json), available_namespaces_path: import_available_namespaces_path(format: :json), create_bulk_import_path: import_bulk_imports_path(format: :json), diff --git a/app/views/import/fogbugz/new.html.haml b/app/views/import/fogbugz/new.html.haml index c0abac0a633..ab836174024 100644 --- a/app/views/import/fogbugz/new.html.haml +++ b/app/views/import/fogbugz/new.html.haml @@ -12,14 +12,14 @@ .form-group.row = label_tag :uri, _('FogBugz URL'), class: 'col-form-label col-md-2' .col-md-4 - = text_field_tag :uri, nil, placeholder: 'https://mycompany.fogbugz.com', class: 'form-control' + = text_field_tag :uri, nil, placeholder: 'https://mycompany.fogbugz.com', class: 'form-control gl-form-input' .form-group.row = label_tag :email, _('FogBugz Email'), class: 'col-form-label col-md-2' .col-md-4 - = text_field_tag :email, nil, class: 'form-control' + = text_field_tag :email, nil, class: 'form-control gl-form-input' .form-group.row = label_tag :password, _('FogBugz Password'), class: 'col-form-label col-md-2' .col-md-4 - = password_field_tag :password, nil, class: 'form-control' + = password_field_tag :password, nil, class: 'form-control gl-form-input' .form-actions = submit_tag _('Continue to the next step'), class: 'gl-button btn btn-confirm' diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml index 285d2fb23a3..27786806d17 100644 --- a/app/views/import/gitea/new.html.haml +++ b/app/views/import/gitea/new.html.haml @@ -13,10 +13,10 @@ .form-group.row = label_tag :gitea_host_url, _('Gitea Host URL'), class: 'col-form-label col-sm-2' .col-sm-4 - = text_field_tag :gitea_host_url, nil, placeholder: 'https://gitea.com', class: 'form-control' + = text_field_tag :gitea_host_url, nil, placeholder: 'https://gitea.com', class: 'form-control gl-form-input' .form-group.row = label_tag :personal_access_token, _('Personal Access Token'), class: 'col-form-label col-sm-2' .col-sm-4 - = text_field_tag :personal_access_token, nil, class: 'form-control' + = text_field_tag :personal_access_token, nil, class: 'form-control gl-form-input' .form-actions = submit_tag _('List Your Gitea Repositories'), class: 'gl-button btn btn-confirm' diff --git a/app/views/import/phabricator/new.html.haml b/app/views/import/phabricator/new.html.haml index 69483512816..960d3df2c42 100644 --- a/app/views/import/phabricator/new.html.haml +++ b/app/views/import/phabricator/new.html.haml @@ -18,10 +18,10 @@ .form-group.row = label_tag :phabricator_server_url, _('Phabricator Server URL'), class: 'col-form-label col-md-2' .col-md-4 - = text_field_tag :phabricator_server_url, params[:phabricator_server_url], class: 'form-control gl-mr-3', placeholder: 'https://your-phabricator-server', size: 40 + = text_field_tag :phabricator_server_url, params[:phabricator_server_url], class: 'form-control gl-form-input gl-mr-3', placeholder: 'https://your-phabricator-server', size: 40 .form-group.row = label_tag :api_token, _('API Token'), class: 'col-form-label col-md-2' .col-md-4 - = password_field_tag :api_token, params[:api_token], class: 'form-control gl-mr-3', placeholder: _('Personal Access Token'), size: 40 + = password_field_tag :api_token, params[:api_token], class: 'form-control gl-form-input gl-mr-3', placeholder: _('Personal Access Token'), size: 40 .form-actions = submit_tag _('Import tasks'), class: 'gl-button btn btn-confirm' diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml index 561c14dc68a..7de8b0ee10f 100644 --- a/app/views/import/shared/_new_project_form.html.haml +++ b/app/views/import/shared/_new_project_form.html.haml @@ -1,7 +1,7 @@ .row .form-group.project-name.col-sm-12 = label_tag :name, _('Project name'), class: 'label-bold' - = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control input-lg", autofocus: true, required: true, aria: { required: true } + = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control gl-form-input input-lg", autofocus: true, required: true, aria: { required: true } .form-group.col-12.col-sm-6 = label_tag :namespace_id, _('Project URL'), class: 'label-bold' .form-group @@ -18,4 +18,4 @@ = hidden_field_tag :namespace_id, current_user.namespace_id .form-group.col-12.col-sm-6.project-path = label_tag :path, _('Project slug'), class: 'label-bold' - = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control", required: true, aria: { required: true } + = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control gl-form-input", required: true, aria: { required: true } diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 6694ad5968a..b28cd47efcc 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -32,6 +32,8 @@ - if page_canonical_link %link{ rel: 'canonical', href: page_canonical_link } + = yield :prefetch_asset_tags + = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png' = render 'layouts/startup_css' diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml index 0ef50d1b122..cd1a236b6be 100644 --- a/app/views/layouts/_loading_hints.html.haml +++ b/app/views/layouts/_loading_hints.html.haml @@ -1,10 +1,11 @@ -- if ActionController::Base.asset_host - %link{ rel: 'dns-prefetch', href: ActionController::Base.asset_host } - %link{ rel: 'preconnect', href: ActionController::Base.asset_host, crossorigin: '' } -- if user_application_theme == 'gl-dark' - %link{ { rel: 'preload', href: stylesheet_url('application_dark'), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} } -- else - %link{ { rel: 'preload', href: stylesheet_url('application'), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} } -%link{ { rel: 'preload', href: stylesheet_url("highlight/themes/#{user_color_scheme}"), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} } -- if Gitlab::CurrentSettings.snowplow_enabled? && Gitlab::CurrentSettings.snowplow_collector_hostname - %link{ rel: 'preconnect', href: Gitlab::CurrentSettings.snowplow_collector_hostname, crossorigin: '' } += cache_if(Feature.enabled?(:cached_loading_hints, current_user), [ActionController::Base.asset_host, user_application_theme, user_color_scheme], expires_in: 1.minute) do + - if ActionController::Base.asset_host + %link{ rel: 'dns-prefetch', href: ActionController::Base.asset_host } + %link{ rel: 'preconnect', href: ActionController::Base.asset_host, crossorigin: '' } + - if user_application_theme == 'gl-dark' + %link{ { rel: 'preload', href: stylesheet_url('application_dark'), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} } + - else + %link{ { rel: 'preload', href: stylesheet_url('application'), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} } + %link{ { rel: 'preload', href: stylesheet_url("highlight/themes/#{user_color_scheme}"), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} } + - if Gitlab::CurrentSettings.snowplow_enabled? && Gitlab::CurrentSettings.snowplow_collector_hostname + %link{ rel: 'preconnect', href: Gitlab::CurrentSettings.snowplow_collector_hostname, crossorigin: '' } diff --git a/app/views/layouts/_page_title.html.haml b/app/views/layouts/_page_title.html.haml deleted file mode 100644 index 54da5074763..00000000000 --- a/app/views/layouts/_page_title.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -- if content_for?(:page-title) - = yield :page-title diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 0251a8b6d7c..6bb51b01c13 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -3,7 +3,7 @@ %ul %li.current-user - if current_user_menu?(:profile) - = link_to current_user, class: 'gl-line-height-20!', data: { user: current_user.username, testid: 'user-profile-link' } do + = link_to current_user, class: 'gl-line-height-20!', data: { user: current_user.username, testid: 'user-profile-link', qa_selector: 'user_profile_link' } do = render 'layouts/header/current_user_dropdown_item' - else .gl-py-3.gl-px-4 diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 481e83c9701..ae333cffb84 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -20,7 +20,7 @@ = _('Next') - if Feature.enabled?(:combined_menu, current_user, default_enabled: :yaml) - = render "layouts/nav/combined_menu" + = render "layouts/nav/top_nav" - else - if current_user = render "layouts/nav/dashboard" @@ -92,7 +92,7 @@ = link_to help_path, class: 'header-help-dropdown-toggle', data: { toggle: "dropdown" } do %span.gl-sr-only = s_('Nav|Help') - = sprite_icon('question') + = sprite_icon('question-o') %span.notification-dot.rounded-circle.gl-absolute = sprite_icon('chevron-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right diff --git a/app/views/layouts/header/_new_dropdown.html.haml b/app/views/layouts/header/_new_dropdown.html.haml index 7b49e6f716e..ca90d2e02fa 100644 --- a/app/views/layouts/header/_new_dropdown.html.haml +++ b/app/views/layouts/header/_new_dropdown.html.haml @@ -1,3 +1,4 @@ +- new_repo_experiment_text = content_for(:new_repo_experiment) %li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" } } = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", id: "js-onboarding-new-project-link", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do = sprite_icon('plus-square') @@ -12,9 +13,9 @@ %li.dropdown-bold-header = _('This group') - if create_group_project - %li= link_to _('New project'), new_project_path(namespace_id: @group.id) + %li= link_to new_repo_experiment_text, new_project_path(namespace_id: @group.id), data: { track_experiment: 'new_repo', track_event: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' } - if create_group_subgroup - %li= link_to _('New subgroup'), new_group_path(parent_id: @group.id) + %li= link_to _('New subgroup'), new_group_path(parent_id: @group.id), data: { track_event: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' } = render_if_exists 'layouts/header/create_epic_new_dropdown_item' = render 'layouts/header/group_invite_members_new_dropdown_item' %li.divider @@ -29,16 +30,18 @@ %li.dropdown-bold-header = _('This project') - if create_project_issue - %li= link_to _('New issue'), new_project_issue_path(@project) + %li= link_to _('New issue'), new_project_issue_path(@project), data: { track_event: 'click_link_new_issue', track_label: 'plus_menu_dropdown' } - if merge_project - %li= link_to _('New merge request'), project_new_merge_request_path(merge_project) + %li= link_to _('New merge request'), project_new_merge_request_path(merge_project), data: { track_event: 'click_link_new_mr', track_label: 'plus_menu_dropdown' } + - if create_project_snippet - %li= link_to _('New snippet'), new_project_snippet_path(@project) + %li= link_to _('New snippet'), new_project_snippet_path(@project), data: { track_event: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' } = render 'layouts/header/project_invite_members_new_dropdown_item' %li.divider %li.dropdown-bold-header GitLab - = content_for :new_repo_experiment + - if current_user.can_create_project? + %li= link_to new_repo_experiment_text, new_project_path, class: 'qa-global-new-project-link', data: { track_experiment: 'new_repo', track_event: 'click_link_new_project', track_label: 'plus_menu_dropdown' } - if current_user.can_create_group? - %li= link_to _('New group'), new_group_path + %li= link_to _('New group'), new_group_path, data: { track_event: 'click_link_new_group', track_label: 'plus_menu_dropdown' } - if current_user.can?(:create_snippet) - %li= link_to _('New snippet'), new_snippet_path, class: 'qa-global-new-snippet-link' + %li= link_to _('New snippet'), new_snippet_path, data: { track_event: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown' }, class: 'qa-global-new-snippet-link' diff --git a/app/views/layouts/header/_new_repo_experiment.html.haml b/app/views/layouts/header/_new_repo_experiment.html.haml index 73f960844cb..aaa13d593cd 100644 --- a/app/views/layouts/header/_new_repo_experiment.html.haml +++ b/app/views/layouts/header/_new_repo_experiment.html.haml @@ -1,7 +1,6 @@ - content_for :new_repo_experiment do - - if current_user&.can_create_project? - - experiment(:new_repo, user: current_user) do |e| - - e.use do - %li= link_to _('New project'), new_project_path, class: 'qa-global-new-project-link', data: { track_experiment: 'new_repo', track_event: 'click_link', track_label: 'plus_menu_dropdown' } - - e.try do - %li= link_to _('New project/repository'), new_project_path, class: 'qa-global-new-project-link', data: { track_experiment: 'new_repo', track_event: 'click_link', track_label: 'plus_menu_dropdown' } + - experiment(:new_repo, user: current_user) do |e| + - e.use do + = _('New project') + - e.try do + = _('New project/repository') diff --git a/app/views/layouts/header/_whats_new_dropdown_item.html.haml b/app/views/layouts/header/_whats_new_dropdown_item.html.haml index 9fe98a54aae..377f0f3271d 100644 --- a/app/views/layouts/header/_whats_new_dropdown_item.html.haml +++ b/app/views/layouts/header/_whats_new_dropdown_item.html.haml @@ -2,5 +2,5 @@ %li %button.gl-justify-content-space-between.gl-align-items-center.js-whats-new-trigger{ type: 'button', class: 'gl-display-flex!' } = _("What's new") - %span.js-whats-new-notification-count.whats-new-notification-count + %span.js-whats-new-notification-count.gl-badge.badge.sm.badge-dark.badge-pill = whats_new_most_recent_release_items_count diff --git a/app/views/layouts/nav/_combined_menu.html.haml b/app/views/layouts/nav/_combined_menu.html.haml deleted file mode 100644 index db5a7012e8f..00000000000 --- a/app/views/layouts/nav/_combined_menu.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%button{ type: 'button', data: { toggle: "dropdown" } } - = sprite_icon('ellipsis_v') - = _('Projects') diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 42e3ae7e717..718b2002422 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -1,5 +1,7 @@ --# WAIT! Before adding more items to the nav bar, please see --# https://gitlab.com/gitlab-org/gitlab-foss/issues/49713 for more information. +-# WARNING! This file is slated to be removed along with the `combined_menu` +-# feature flag. The logic here will be migrated to an upcoming `top_nav_helper`. +-# Please see [this MR][1] for more context. +-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587 %ul.list-unstyled.navbar-sub-nav - if dashboard_nav_link?(:projects) = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown", data: { track_label: "projects_dropdown", track_event: "click_dropdown", track_experiment: "new_repo" } }) do diff --git a/app/views/layouts/nav/_explore.html.haml b/app/views/layouts/nav/_explore.html.haml index 7d18cd8978b..5b47eb27b04 100644 --- a/app/views/layouts/nav/_explore.html.haml +++ b/app/views/layouts/nav/_explore.html.haml @@ -1,3 +1,7 @@ +-# WARNING! This file is slated to be removed along with the `combined_menu` +-# feature flag. The logic here will be migrated to an upcoming `top_nav_helper`. +-# Please see [this MR][1] for more context. +-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587 %ul.list-unstyled.navbar-sub-nav - if explore_nav_link?(:projects) = nav_link(path: ['dashboard#show', 'root#show', 'projects#trending', 'projects#starred', 'projects#index'], html_options: {class: 'home'}) do diff --git a/app/views/layouts/nav/_top_nav.html.haml b/app/views/layouts/nav/_top_nav.html.haml new file mode 100644 index 00000000000..50c003f8e13 --- /dev/null +++ b/app/views/layouts/nav/_top_nav.html.haml @@ -0,0 +1,7 @@ +- view_model = top_nav_view_model(project: @project, group: @group) +%ul.list-unstyled.navbar-sub-nav#js-top-nav{ data: { view_model: view_model.to_json } } + %li + %a.top-nav-toggle{ href: '#', type: 'button', data: { toggle: "dropdown" } } + = sprite_icon('dot-grid', css_class: "dropdown-icon") + = view_model[:activeTitle] + = sprite_icon('chevron-down') diff --git a/app/views/layouts/nav/groups_dropdown/_show.html.haml b/app/views/layouts/nav/groups_dropdown/_show.html.haml index a9d88341a19..036647e2be1 100644 --- a/app/views/layouts/nav/groups_dropdown/_show.html.haml +++ b/app/views/layouts/nav/groups_dropdown/_show.html.haml @@ -1,5 +1,9 @@ +-# WARNING! This file is slated to be removed along with the `combined_menu` +-# feature flag. The logic here will be migrated to an upcoming `top_nav_helper`. +-# Please see [this MR][1] for more context. +-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587 - group_meta = { id: @group.id, name: @group.name, namespace: @group.full_name, web_url: group_path(@group), avatar_url: @group.avatar_url } if @group&.persisted? -.frequent-items-dropdown-container +.frequent-items-dropdown-container.with-deprecated-styles .frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar %ul = nav_link(path: 'dashboard/groups#index') do @@ -9,10 +13,10 @@ = link_to explore_groups_path, data: { track_label: "groups_dropdown_explore_groups", track_event: "click_link" } do = _('Explore groups') = nav_link(path: 'groups/new#create-group-pane', html_options: { class: 'gl-border-0 gl-border-t-1 gl-border-solid gl-border-gray-100' }) do - = link_to new_group_path(anchor: 'create-group-pane'), data: { track_label: "groups_dropdown_create_group", track_event: "click_link" } do + = link_to new_group_path(anchor: 'create-group-pane'), data: { track_label: "groups_dropdown_create_group", track_event: "click_link", qa_selector: 'create_group_link' } do = _('Create group') = nav_link(path: 'groups/new#import-group-pane') do - = link_to new_group_path(anchor: 'import-group-pane'), data: { track_label: "groups_dropdown_import_group", track_event: "click_link" } do + = link_to new_group_path(anchor: 'import-group-pane'), data: { track_label: "groups_dropdown_import_group", track_event: "click_link", qa_selector: 'import_group_link' } do = _('Import group') .frequent-items-dropdown-content #js-groups-dropdown{ data: { user_name: current_user.username, group: group_meta } } diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml index b95a9cdb00f..2517508ba6c 100644 --- a/app/views/layouts/nav/projects_dropdown/_show.html.haml +++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml @@ -1,5 +1,9 @@ +-# WARNING! This file is slated to be removed along with the `combined_menu` +-# feature flag. The logic here will be migrated to an upcoming `top_nav_helper`. +-# Please see [this MR][1] for more context. +-# [1]: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/56587 - project_meta = { id: @project.id, name: @project.name, namespace: @project.full_name, web_url: project_path(@project), avatar_url: @project.avatar_url } if @project&.persisted? -.frequent-items-dropdown-container +.frequent-items-dropdown-container.with-deprecated-styles .frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar %ul = nav_link(path: 'dashboard/projects#index') do diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index d756867541b..b71866c9138 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -2,9 +2,9 @@ .nav-sidebar-inner-scroll .context-header = link_to admin_root_path, title: _('Admin Overview') do - .avatar-container.s40.settings-avatar + %span.avatar-container.s40.settings-avatar = sprite_icon('admin', size: 24) - .sidebar-context-title + %span.sidebar-context-title = _('Admin Area') %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } } = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers), html_options: {class: 'home'}) do @@ -202,17 +202,18 @@ = render_if_exists 'layouts/nav/sidebar/credentials_link' - = nav_link(controller: :services) do - = link_to admin_application_settings_services_path do - .nav-icon-container - = sprite_icon('template') - %span.nav-item-name - = _('Service Templates') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :services, html_options: { class: "fly-out-top-item" } ) do - = link_to admin_application_settings_services_path do - %strong.fly-out-top-item-name - = _('Service Templates') + - if show_service_templates_nav_link? + = nav_link(controller: :services) do + = link_to admin_application_settings_services_path do + .nav-icon-container + = sprite_icon('template') + %span.nav-item-name + = _('Service Templates') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(controller: :services, html_options: { class: "fly-out-top-item" } ) do + = link_to admin_application_settings_services_path do + %strong.fly-out-top-item-name + = _('Service Templates') = nav_link(controller: :labels) do = link_to admin_labels_path do diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 41bec996de1..757f95f864a 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -1,15 +1,14 @@ - issues_count = cached_issuables_count(@group, type: :issues) - merge_requests_count = group_open_merge_requests_count(@group) - aside_title = @group.subgroup? ? _('Subgroup navigation') : _('Group navigation') -- overview_title = @group.subgroup? ? _('Subgroup overview') : _('Group overview') %aside.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **sidebar_tracking_attributes_by_object(@group), 'aria-label': aside_title } .nav-sidebar-inner-scroll .context-header = link_to group_path(@group), title: @group.name do - .avatar-container.rect-avatar.s40.group-avatar + %span.avatar-container.rect-avatar.s40.group-avatar = group_icon(@group, class: "avatar s40 avatar-tile") - .sidebar-context-title + %span.sidebar-context-title = @group.name %ul.sidebar-top-level-items.qa-group-sidebar = render_if_exists 'layouts/nav/sidebar/group_trial_status_widget', group: @group @@ -19,21 +18,23 @@ = nav_link(path: paths, unless: -> { current_path?('groups/contribution_analytics#show') }, html_options: { class: 'home' }) do = link_to group_path(@group) do .nav-icon-container - = sprite_icon('home') + - sprite = Feature.enabled?(:sidebar_refactor, current_user) ? 'group' : 'home' + = sprite_icon(sprite) %span.nav-item-name - = overview_title + = group_information_title(@group) %ul.sidebar-sub-level-items - = nav_link(path: ['groups#show', 'groups#details', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do + = nav_link(path: paths, html_options: { class: "fly-out-top-item" } ) do = link_to group_path(@group) do %strong.fly-out-top-item-name - = overview_title + = group_information_title(@group) %li.divider.fly-out-top-item - = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do - = link_to details_group_path(@group), title: _('Group details') do - %span - = _('Details') + - if Feature.disabled?(:sidebar_refactor, current_user) + = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do + = link_to details_group_path(@group), title: _('Group details') do + %span + = _('Details') - if group_sidebar_link?(:activity) = nav_link(path: 'groups#activity') do @@ -41,6 +42,19 @@ %span = _('Activity') + - if group_sidebar_link?(:labels) && Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml) + = nav_link(path: 'labels#index') do + = link_to group_labels_path(@group), title: _('Labels') do + %span + = _('Labels') + + - if Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml) + - if group_sidebar_link?(:group_members) + = nav_link(path: 'group_members#index') do + = link_to group_group_members_path(@group), title: _('Members'), data: { qa_selector: 'group_members_item' } do + %span + = _('Members') + = render_if_exists "layouts/nav/ee/epic_link", group: @group - if group_sidebar_link?(:issues) @@ -53,7 +67,7 @@ %span.badge.badge-pill.count= issues_count %ul.sidebar-sub-level-items{ data: { qa_selector: 'group_issues_sidebar_submenu'} } - = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index', 'iterations#index'], html_options: { class: "fly-out-top-item" } ) do + = nav_link(path: group_issues_sub_menu_items, html_options: { class: "fly-out-top-item" } ) do = link_to issues_group_path(@group) do %strong.fly-out-top-item-name = _('Issues') @@ -71,7 +85,7 @@ %span = boards_link_text - - if group_sidebar_link?(:labels) + - if group_sidebar_link?(:labels) && Feature.disabled?(:sidebar_refactor, current_user, default_enabled: :yaml) = nav_link(path: 'labels#index') do = link_to group_labels_path(@group), title: _('Labels') do %span @@ -124,25 +138,26 @@ - if group_sidebar_link?(:wiki) = render 'layouts/nav/sidebar/wiki_link', wiki_url: @group.wiki.web_url - - if group_sidebar_link?(:group_members) - = nav_link(path: 'group_members#index') do - = link_to group_group_members_path(@group) do - .nav-icon-container - = sprite_icon('users') - %span.nav-item-name.qa-group-members-item - = _('Members') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do - = link_to group_group_members_path(@group) do - %strong.fly-out-top-item-name - = _('Members') + - if Feature.disabled?(:sidebar_refactor, current_user, default_enabled: :yaml) + - if group_sidebar_link?(:group_members) + = nav_link(path: 'group_members#index') do + = link_to group_group_members_path(@group) do + .nav-icon-container + = sprite_icon('users') + %span.nav-item-name.qa-group-members-item + = _('Members') + %ul.sidebar-sub-level-items.is-fly-out-only + = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do + = link_to group_group_members_path(@group) do + %strong.fly-out-top-item-name + = _('Members') - if group_sidebar_link?(:settings) = nav_link(path: group_settings_nav_link_paths) do = link_to edit_group_path(@group) do .nav-icon-container = sprite_icon('settings') - %span.nav-item-name.qa-group-settings-item + %span.nav-item-name{ data: { qa_selector: 'group_settings' } } = _('Settings') %ul.sidebar-sub-level-items.qa-group-sidebar-submenu{ data: { testid: 'group-settings-menu' } } = nav_link(path: %w[groups#projects groups#edit badges#index ci_cd#show groups/applications#index], html_options: { class: "fly-out-top-item" } ) do @@ -170,7 +185,7 @@ %span = _('Repository') - = nav_link(controller: :ci_cd) do + = nav_link(controller: [:ci_cd, 'groups/runners']) do = link_to group_settings_ci_cd_path(@group), title: _('CI/CD') do %span = _('CI/CD') diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index dda5e6b9636..63b97e3133c 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -2,9 +2,9 @@ .nav-sidebar-inner-scroll .context-header = link_to profile_path, title: _('Profile Settings') do - .avatar-container.s40.settings-avatar + %span.avatar-container.s40.settings-avatar = image_tag avatar_icon_for_user(current_user, 40), class: "avatar s40 avatar-tile js-sidebar-user-avatar", alt: current_user.name, data: { testid: 'sidebar-user-avatar' } - .sidebar-context-title= _('User Settings') + %span.sidebar-context-title= _('User Settings') %ul.sidebar-top-level-items = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do = link_to profile_path do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 3d0c6baffd5..a06f9f8d6ef 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -1,3 +1 @@ --# We're migration the project sidebar to a logical model based structure. If you need to update --# any of the existing menus, you can find them in app/views/layouts/nav/sidebar/_project_menus.html.haml. = render partial: 'shared/nav/sidebar', object: Sidebars::Projects::Panel.new(project_sidebar_context(@project, current_user, current_ref)) diff --git a/app/views/layouts/nav/sidebar/_project_menus.html.haml b/app/views/layouts/nav/sidebar/_project_menus.html.haml deleted file mode 100644 index ed072c0f6a2..00000000000 --- a/app/views/layouts/nav/sidebar/_project_menus.html.haml +++ /dev/null @@ -1,380 +0,0 @@ -- if project_nav_tab? :issues - = nav_link(controller: @project.issues_enabled? ? ['projects/issues', :labels, :milestones, :boards, :iterations] : 'projects/issues') do - = link_to project_issues_path(@project), class: 'shortcuts-issues qa-issues-item' do - .nav-icon-container - = sprite_icon('issues') - %span.nav-item-name#js-onboarding-issues-link - = _('Issues') - - if @project.issues_enabled? - %span.badge.badge-pill.count.issue_counter - = number_with_delimiter(@project.open_issues_count(current_user)) - - %ul.sidebar-sub-level-items - = nav_link(controller: 'projects/issues', action: :index, html_options: { class: "fly-out-top-item" } ) do - = link_to project_issues_path(@project) do - %strong.fly-out-top-item-name - = _('Issues') - - if @project.issues_enabled? - %span.badge.badge-pill.count.issue_counter.fly-out-badge - = number_with_delimiter(@project.open_issues_count(current_user)) - %li.divider.fly-out-top-item - = nav_link(controller: :issues, action: :index) do - = link_to project_issues_path(@project), title: _('Issues') do - %span - = _('List') - - = nav_link(controller: :boards) do - = link_to project_boards_path(@project), title: boards_link_text, data: { qa_selector: "issue_boards_link" } do - %span - = boards_link_text - - = nav_link(controller: :labels) do - = link_to project_labels_path(@project), title: _('Labels'), class: 'qa-labels-link' do - %span - = _('Labels') - - = render 'projects/sidebar/issues_service_desk' - - = nav_link(controller: :milestones) do - = link_to project_milestones_path(@project), title: _('Milestones'), class: 'qa-milestones-link' do - %span - = _('Milestones') - - = render_if_exists 'layouts/nav/sidebar/project_iterations_link' - -- if project_nav_tab?(:external_issue_tracker) - - issue_tracker = @project.external_issue_tracker - - if issue_tracker.is_a?(JiraService) && project_jira_issues_integration? - = render_if_exists 'layouts/nav/sidebar/project_jira_issues_link', issue_tracker: issue_tracker - - else - = nav_link do - = link_to issue_tracker.issue_tracker_path, target: '_blank', rel: 'noopener noreferrer', class: 'shortcuts-external_tracker' do - .nav-icon-container - = sprite_icon('external-link') - %span.nav-item-name - = issue_tracker.title - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(html_options: { class: "fly-out-top-item" } ) do - = link_to issue_tracker.issue_tracker_path, target: '_blank', rel: 'noopener noreferrer' do - %strong.fly-out-top-item-name - = issue_tracker.title - -- if (project_nav_tab? :labels) && !@project.issues_enabled? - = nav_link(controller: [:labels]) do - = link_to project_labels_path(@project), title: _('Labels'), class: 'shortcuts-labels qa-labels-items' do - .nav-icon-container - = sprite_icon('label') - %span.nav-item-name#js-onboarding-labels-link - = _('Labels') - -- if project_nav_tab? :merge_requests - = nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :milestones]) do - = link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests', data: { qa_selector: 'merge_requests_link' } do - .nav-icon-container - = sprite_icon('git-merge') - %span.nav-item-name#js-onboarding-mr-link - = _('Merge requests') - %span.badge.badge-pill.count.merge_counter.js-merge-counter - = number_with_delimiter(@project.open_merge_requests_count) - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :merge_requests, html_options: { class: "fly-out-top-item" } ) do - = link_to project_merge_requests_path(@project) do - %strong.fly-out-top-item-name - = _('Merge requests') - %span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge - = number_with_delimiter(@project.open_merge_requests_count) - -= render_if_exists "layouts/nav/requirements_link", project: @project - -- if project_nav_tab? :pipelines - = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts, :test_cases, :pipeline_editor], unless: -> { current_path?('projects/pipelines#charts') }) do - = link_to project_pipelines_path(@project), class: 'shortcuts-pipelines qa-link-pipelines rspec-link-pipelines', data: { qa_selector: 'ci_cd_link' } do - .nav-icon-container - = sprite_icon('rocket') - %span.nav-item-name#js-onboarding-pipelines-link - = _('CI/CD') - - %ul.sidebar-sub-level-items - = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts, :test_cases, :pipeline_editor], html_options: { class: "fly-out-top-item" }) do - = link_to project_pipelines_path(@project) do - %strong.fly-out-top-item-name - = _('CI/CD') - %li.divider.fly-out-top-item - - if project_nav_tab? :pipelines - = nav_link(path: ['pipelines#index', 'pipelines#show']) do - = link_to project_pipelines_path(@project), title: _('Pipelines'), class: 'shortcuts-pipelines' do - %span - = _('Pipelines') - - - if can_view_pipeline_editor?(@project) - = nav_link(controller: :pipeline_editor, action: :show) do - = link_to project_ci_pipeline_editor_path(@project), title: s_('Pipelines|Editor') do - %span - = s_('Pipelines|Editor') - - - if project_nav_tab? :builds - = nav_link(controller: :jobs) do - = link_to project_jobs_path(@project), title: _('Jobs'), class: 'shortcuts-builds' do - %span - = _('Jobs') - - - if Feature.enabled?(:artifacts_management_page, @project) - = nav_link(controller: :artifacts, action: :index) do - = link_to project_artifacts_path(@project), title: _('Artifacts'), class: 'shortcuts-builds' do - %span - = _('Artifacts') - - - if project_nav_tab?(:pipelines) - = nav_link(controller: :pipeline_schedules) do - = link_to pipeline_schedules_path(@project), title: _('Schedules'), class: 'shortcuts-builds' do - %span - = _('Schedules') - - = render_if_exists "layouts/nav/test_cases_link", project: @project - -- if project_nav_tab? :security_and_compliance - = render_if_exists 'layouts/nav/sidebar/project_security_link' # EE-specific - -- if project_nav_tab? :operations - = nav_link(controller: sidebar_operations_paths) do - = link_to sidebar_operations_link_path, class: 'shortcuts-operations', data: { qa_selector: 'operations_link' } do - .nav-icon-container - = sprite_icon('cloud-gear') - %span.nav-item-name - = _('Operations') - - %ul.sidebar-sub-level-items - = nav_link(controller: sidebar_operations_paths, html_options: { class: "fly-out-top-item" } ) do - = link_to sidebar_operations_link_path do - %strong.fly-out-top-item-name - = _('Operations') - %li.divider.fly-out-top-item - - - if project_nav_tab? :metrics_dashboards - = nav_link(controller: :metrics_dashboard, action: [:show]) do - = link_to project_metrics_dashboard_path(@project), title: _('Metrics'), class: 'shortcuts-metrics', data: { qa_selector: 'operations_metrics_link' } do - %span - = _('Metrics') - - - if project_nav_tab?(:environments) && can?(current_user, :read_pod_logs, @project) - = nav_link(controller: :logs, action: [:index]) do - = link_to project_logs_path(@project), title: _('Logs') do - %span - = _('Logs') - - - if project_nav_tab? :environments - = render "layouts/nav/sidebar/tracing_link" - - - if project_nav_tab?(:error_tracking) - = nav_link(controller: :error_tracking) do - = link_to project_error_tracking_index_path(@project), title: _('Error Tracking') do - %span - = _('Error Tracking') - - - if project_nav_tab?(:alert_management) - = nav_link(controller: :alert_management) do - = link_to project_alert_management_index_path(@project), title: _('Alerts') do - %span - = _('Alerts') - - - if project_nav_tab?(:incidents) - = nav_link(controller: :incidents) do - = link_to project_incidents_path(@project), title: _('Incidents'), data: { qa_selector: 'operations_incidents_link' } do - %span - = _('Incidents') - - = render_if_exists 'projects/sidebar/oncall_schedules' - - - if project_nav_tab? :serverless - = nav_link(controller: :functions) do - = link_to project_serverless_functions_path(@project), title: _('Serverless') do - %span - = _('Serverless') - - - if project_nav_tab? :terraform - = nav_link(controller: :terraform) do - = link_to project_terraform_index_path(@project), title: _('Terraform') do - %span - = _('Terraform') - - - if project_nav_tab? :clusters - - show_cluster_hint = show_gke_cluster_integration_callout?(@project) - = nav_link(controller: [:cluster_agents, :clusters]) do - = link_to project_clusters_path(@project), title: _('Kubernetes'), class: 'shortcuts-kubernetes' do - %span - = _('Kubernetes') - - if show_cluster_hint - .js-feature-highlight{ disabled: true, - data: { trigger: 'manual', - container: 'body', - placement: 'right', - highlight: UserCalloutsHelper::GKE_CLUSTER_INTEGRATION, - highlight_priority: UserCallout.feature_names[:GKE_CLUSTER_INTEGRATION], - dismiss_endpoint: user_callouts_path, - auto_devops_help_path: help_page_path('topics/autodevops/index.md') } } - - if project_nav_tab? :environments - = nav_link(controller: :environments, action: [:index, :folder, :show, :new, :edit, :create, :update, :stop, :terminal]) do - = link_to project_environments_path(@project), title: _('Environments'), class: 'shortcuts-environments qa-operations-environments-link' do - %span - = _('Environments') - - - if project_nav_tab? :feature_flags - = nav_link(controller: :feature_flags) do - = link_to project_feature_flags_path(@project), title: _('Feature Flags'), class: 'shortcuts-feature-flags' do - %span - = _('Feature Flags') - - - if project_nav_tab?(:product_analytics) - = nav_link(controller: :product_analytics) do - = link_to project_product_analytics_path(@project), title: _('Product Analytics') do - %span - = _('Product Analytics') - -= render_if_exists 'layouts/nav/sidebar/project_packages_link' - -- if project_nav_tab? :analytics - = render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user) - -- if project_nav_tab?(:confluence) - - confluence_url = project_wikis_confluence_path(@project) - = nav_link do - = link_to confluence_url, class: 'shortcuts-confluence' do - .nav-icon-container - = image_tag 'confluence.svg', alt: _('Confluence') - %span.nav-item-name - = _('Confluence') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(html_options: { class: 'fly-out-top-item' } ) do - = link_to confluence_url, target: '_blank', rel: 'noopener noreferrer' do - %strong.fly-out-top-item-name - = _('Confluence') - -- if project_nav_tab? :wiki - = render 'layouts/nav/sidebar/wiki_link', wiki_url: wiki_path(@project.wiki) - -- if project_nav_tab?(:external_wiki) - - external_wiki_url = @project.external_wiki.external_wiki_url - = nav_link do - = link_to external_wiki_url, class: 'shortcuts-external_wiki' do - .nav-icon-container - = sprite_icon('external-link') - %span.nav-item-name - = s_('ExternalWikiService|External wiki') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(html_options: { class: "fly-out-top-item" } ) do - = link_to external_wiki_url do - %strong.fly-out-top-item-name - = s_('ExternalWikiService|External wiki') - -- if project_nav_tab? :snippets - = nav_link(controller: :snippets) do - = link_to project_snippets_path(@project), class: 'shortcuts-snippets', data: { qa_selector: 'snippets_link' } do - .nav-icon-container - = sprite_icon('snippet') - %span.nav-item-name - = _('Snippets') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(controller: :snippets, html_options: { class: "fly-out-top-item" } ) do - = link_to project_snippets_path(@project) do - %strong.fly-out-top-item-name - = _('Snippets') - -= nav_link(controller: :project_members) do - = link_to project_project_members_path(@project), title: _('Members'), class: 'qa-members-link', id: 'js-onboarding-members-link' do - .nav-icon-container - = sprite_icon('users') - %span.nav-item-name - = _('Members') - %ul.sidebar-sub-level-items.is-fly-out-only - = nav_link(path: %w[members#show], html_options: { class: "fly-out-top-item" } ) do - = link_to project_project_members_path(@project) do - %strong.fly-out-top-item-name - = _('Members') - -- if project_nav_tab? :settings - = nav_link(path: sidebar_settings_paths) do - = link_to edit_project_path(@project) do - .nav-icon-container - = sprite_icon('settings') - %span.nav-item-name.qa-settings-item#js-onboarding-settings-link - = _('Settings') - - %ul.sidebar-sub-level-items - - can_edit = can?(current_user, :admin_project, @project) - - if can_edit - = nav_link(path: sidebar_settings_paths, html_options: { class: "fly-out-top-item" } ) do - = link_to edit_project_path(@project) do - %strong.fly-out-top-item-name - = _('Settings') - %li.divider.fly-out-top-item - = nav_link(path: %w[projects#edit]) do - = link_to edit_project_path(@project), title: _('General'), class: 'qa-general-settings-link' do - %span - = _('General') - - if can_edit - = nav_link(controller: [:integrations, :services]) do - = link_to project_settings_integrations_path(@project), title: _('Integrations'), data: { qa_selector: 'integrations_settings_link' } do - %span - = _('Integrations') - = nav_link(controller: [:hooks, :hook_logs]) do - = link_to project_hooks_path(@project), title: _('Webhooks'), data: { qa_selector: 'webhooks_settings_link' } do - %span - = _('Webhooks') - - if can?(current_user, :read_resource_access_tokens, @project) - = nav_link(controller: [:access_tokens]) do - = link_to project_settings_access_tokens_path(@project), title: _('Access Tokens'), data: { qa_selector: 'access_tokens_settings_link' } do - %span - = _('Access Tokens') - = nav_link(controller: :repository) do - = link_to project_settings_repository_path(@project), title: _('Repository') do - %span - = _('Repository') - - if !@project.archived? && @project.feature_available?(:builds, current_user) - = nav_link(controller: :ci_cd) do - = link_to project_settings_ci_cd_path(@project), title: _('CI/CD') do - %span - = _('CI/CD') - - if settings_operations_available? - = nav_link(controller: [:operations]) do - = link_to project_settings_operations_path(@project), title: _('Operations'), data: { qa_selector: 'operations_settings_link' } do - = _('Operations') - - if @project.pages_available? - = nav_link(controller: :pages) do - = link_to project_pages_path(@project), title: _('Pages') do - %span - = _('Pages') - --# Shortcut to Project > Activity -%li.hidden - = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do - %span - = _('Activity') - --# Shortcut to Repository > Graph (formerly, Network) -- if project_nav_tab? :network - %li.hidden - = link_to project_network_path(@project, current_ref), title: _('Network'), class: 'shortcuts-network' do - = _('Graph') - --# Shortcut to Issues > New Issue -- if project_nav_tab?(:issues) - %li.hidden - = link_to new_project_issue_path(@project), class: 'shortcuts-new-issue' do - = _('Create a new issue') - --# Shortcut to Pipelines > Jobs -- if project_nav_tab? :builds - %li.hidden - = link_to project_jobs_path(@project), title: _('Jobs'), class: 'shortcuts-builds' do - = _('Jobs') - --# Shortcut to commits page -- if project_nav_tab? :commits - %li.hidden - = link_to project_commits_path(@project), title: _('Commits'), class: 'shortcuts-commits' do - = _('Commits') - --# Shortcut to issue boards -- if project_nav_tab?(:issues) - %li.hidden - = link_to _('Issue Boards'), project_boards_path(@project), title: _('Issue Boards'), class: 'shortcuts-issue-boards' diff --git a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml b/app/views/layouts/nav/sidebar/_project_packages_link.html.haml deleted file mode 100644 index b28468a7969..00000000000 --- a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml +++ /dev/null @@ -1,27 +0,0 @@ -- packages_link = project_nav_tab?(:packages) ? project_packages_path(@project) : project_container_registry_index_path(@project) - -- if (project_nav_tab?(:packages) || project_nav_tab?(:container_registry)) - = nav_link controller: [:packages, :repositories, :infrastructure_registry] do - = link_to packages_link, data: { qa_selector: 'packages_link' } do - .nav-icon-container - = sprite_icon('package') - %span.nav-item-name - = _('Packages & Registries') - %ul.sidebar-sub-level-items - = nav_link(controller: [:packages, :repositories, :infrastructure_registry], html_options: { class: "fly-out-top-item" } ) do - = link_to packages_link do - %strong.fly-out-top-item-name - = _('Packages & Registries') - %li.divider.fly-out-top-item - - if project_nav_tab? :packages - = nav_link controller: :packages do - = link_to project_packages_path(@project), title: _('Package Registry') do - %span= _('Package Registry') - - if project_nav_tab? :container_registry - = nav_link controller: :repositories do - = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry', title: _('Container Registry') do - %span= _('Container Registry') - - if project_nav_tab? :infrastructure_registry - = nav_link controller: :infrastructure_registry do - = link_to project_infrastructure_registry_index_path(@project), title: _('Infrastructure Registry') do - %span= _('Infrastructure Registry') diff --git a/app/views/layouts/nav/sidebar/_project_security_link.html.haml b/app/views/layouts/nav/sidebar/_project_security_link.html.haml deleted file mode 100644 index 426845639e3..00000000000 --- a/app/views/layouts/nav/sidebar/_project_security_link.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -- top_level_link = project_security_configuration_path(@project) -- top_level_qa_selector = 'security_configuration_link' -- if any_project_nav_tab?([:security_configuration]) - = nav_link(path: sidebar_security_paths) do - = link_to top_level_link, data: { qa_selector: top_level_qa_selector } do - .nav-icon-container - = sprite_icon('shield') - %span.nav-item-name - = _('Security & Compliance') - - %ul.sidebar-sub-level-items - = nav_link(path: sidebar_security_paths, html_options: { class: "fly-out-top-item" } ) do - = link_to top_level_link do - %strong.fly-out-top-item-name - = _('Security & Compliance') - - %li.divider.fly-out-top-item - - if project_nav_tab?(:security_configuration) - = nav_link(path: sidebar_security_configuration_paths) do - = link_to project_security_configuration_path(@project), title: _('Configuration'), data: { qa_selector: 'security_configuration_link'} do - %span= _('Configuration') diff --git a/app/views/layouts/nav/sidebar/_tracing_link.html.haml b/app/views/layouts/nav/sidebar/_tracing_link.html.haml deleted file mode 100644 index 7a31a20f5f0..00000000000 --- a/app/views/layouts/nav/sidebar/_tracing_link.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -- return unless can?(current_user, :read_environment, @project) - -- if project_nav_tab? :settings - = nav_link(controller: :tracings, action: [:show]) do - = link_to project_tracing_path(@project), title: _('Tracing') do - %span - = _('Tracing') diff --git a/app/views/layouts/simple_registration.html.haml b/app/views/layouts/simple_registration.html.haml new file mode 100644 index 00000000000..dc7ec25c96e --- /dev/null +++ b/app/views/layouts/simple_registration.html.haml @@ -0,0 +1,10 @@ +!!! 5 +%html{ lang: "en" } + = render "layouts/head" + %body.login-page.application.navless{ class: user_application_theme, data: { page: body_data_page } } + = render "layouts/header/logo_with_title" + = render "layouts/broadcast" + .container.navless-container.pt-0 + .content.mw-460.mx-auto + = render "layouts/flash" + = yield diff --git a/app/views/notify/change_in_merge_request_draft_status_email.html.haml b/app/views/notify/change_in_merge_request_draft_status_email.html.haml index 5604a30d9f1..64ceb77e85c 100644 --- a/app/views/notify/change_in_merge_request_draft_status_email.html.haml +++ b/app/views/notify/change_in_merge_request_draft_status_email.html.haml @@ -1,2 +1,2 @@ -%p - = _('%{username} changed the draft status of merge request %{mr_reference}' % {username: sanitize_name(@updated_by_user.name), mr_reference: @merge_request.to_reference }) +%p= html_escape(_('%{username} changed the draft status of merge request %{mr_link}')) % { username: link_to(@updated_by_user.name, user_url(@updated_by_user)), + mr_link: merge_request_reference_link(@merge_request) } diff --git a/app/views/notify/in_product_marketing_email.html.haml b/app/views/notify/in_product_marketing_email.html.haml index 015a12bbb6d..a1c3ecfb87e 100644 --- a/app/views/notify/in_product_marketing_email.html.haml +++ b/app/views/notify/in_product_marketing_email.html.haml @@ -163,43 +163,43 @@ %table{ border: "0", cellpadding: "0", cellspacing: "0", role: "presentation", width: "100%" } %tr %td{ align: "left", style: "padding: 0 20px;" } - = about_link('mailers/in_product_marketing', 'gitlab-logo-gray-rgb.png', 200) + = about_link('mailers/in_product_marketing/gitlab-logo-gray-rgb.png', 200) %tr %td{ "aria-hidden" => "true", height: "30", style: "font-size: 0; line-height: 0;" } %tr{ style: "background-color: #ffffff;" } %td{ style: "color: #424242; padding: 10px 30px; text-align: center; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;font-size: 16px; line-height: 22px; border: 1px solid #dddddd" } %p - = in_product_marketing_progress(@track, @series, format: :html).html_safe + = @message.progress.html_safe %tr %td{ bgcolor: "#ffffff", height: "auto", style: "max-width: 600px; width: 100%; text-align: center; height: 200px; padding: 25px 15px; mso-line-height-rule: exactly; min-height: 40px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;", valign: "middle", width: "100%" } - = in_product_marketing_logo(@track, @series) + = inline_image_link(@message.logo_path, { width: '150', style: 'width: 150px;' }) %h1{ style: "font-size: 40px; line-height: 46x; color: #000000; padding: 20px 0 0 0; font-weight: normal;" } - = in_product_marketing_title(@track, @series) + = @message.title %h2{ style: "font-size: 28px; line-height: 34px; color: #000000; padding: 0; font-weight: 400;" } - = in_product_marketing_subtitle(@track, @series) + = @message.subtitle %tr %td{ style: "padding: 10px 20px 30px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#000000; font-size: 18px; line-height: 24px;" } %p{ style: "margin: 0 0 20px 0;" } - = in_product_marketing_body_line1(@track, @series, format: :html).html_safe - - in_product_marketing_body_line2(@track, @series, format: :html)&.tap do |line| + = @message.body_line1.html_safe + - @message.body_line2&.tap do |line| %p{ style: "margin: 0 0 20px 0;" } = line.html_safe %tr %td{ align: "center", style: "padding: 10px 20px 80px 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } - .cta_link= cta_link(@track, @series, @group, format: :html) + .cta_link= @message.cta_link %tr{ style: "background-color: #ffffff;" } %td{ align: "center", style: "padding:75px 20px 25px;" } - = about_link('', 'gitlab_logo.png', 80) + = about_link('gitlab_logo.png', 80) %tr{ style: "background-color: #ffffff;" } %td{ align: "center", style: "padding:0px ;" } %tr{ style: "background-color: #ffffff;" } %td{ align: "center", style: "padding:0px 10px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; " } %span.footernav{ style: "color: #6e49cb; font-size: 16px; line-height: 26px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } - = footer_links(format: :html).join(' ' * 3 + '|' + ' ' * 4).html_safe + = @message.footer_links.join(' ' * 3 + '|' + ' ' * 4).html_safe %tr{ style: "background-color:#ffffff;" } %td{ align: "center", style: "padding: 40px 30px 20px 30px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif;" } - .address= address(format: :html) + .address= @message.address %tr{ style: "background-color: #ffffff;" } %td{ align: "left", style: "padding:20px 30px 20px 30px;" } %span.footernav{ style: "color: #6e49cb; font-size: 14px; line-height: 20px; font-family: 'Source Sans Pro', helvetica, arial, sans-serif; color:#424242;" } - = unsubscribe(@track, @series, format: :html).html_safe + = @message.unsubscribe.html_safe diff --git a/app/views/notify/in_product_marketing_email.text.erb b/app/views/notify/in_product_marketing_email.text.erb index bc8315e49a0..7d0fe7aec6d 100644 --- a/app/views/notify/in_product_marketing_email.text.erb +++ b/app/views/notify/in_product_marketing_email.text.erb @@ -1,14 +1,14 @@ -<%= in_product_marketing_tagline(@track, @series) %> +<%= @message.tagline %> -<%= in_product_marketing_title(@track, @series) %> -<%= in_product_marketing_subtitle(@track, @series) %> +<%= @message.title %> +<%= @message.subtitle %> -<%= in_product_marketing_body_line1(@track, @series) %> +<%= @message.body_line1 %> -<%= in_product_marketing_body_line2(@track, @series) %> +<%= @message.body_line2 %> -<%= cta_link(@track, @series, @group) %> +<%= @message.cta_link %> @@ -16,8 +16,8 @@ -<%= footer_links %> +<%= @message.footer_links %> -<%= address %> +<%= @message.address %> -<%= unsubscribe(@track, @series) %> +<%= @message.unsubscribe %> diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml index 7f0a50e9248..3219ee34736 100644 --- a/app/views/notify/new_issue_email.html.haml +++ b/app/views/notify/new_issue_email.html.haml @@ -1,5 +1,6 @@ %p.details - #{link_to @issue.author_name, user_url(@issue.author)} created an issue #{issue_reference_link(@issue)}: + = html_escape(_('%{user} created an issue: %{issue_link}')) % { user: link_to(@issue.author_name, user_url(@issue.author)), + issue_link: issue_reference_link(@issue) } - if @issue.assignees.any? %p diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb index bd61db3ee76..7c78d67316d 100644 --- a/app/views/notify/new_issue_email.text.erb +++ b/app/views/notify/new_issue_email.text.erb @@ -1,4 +1,5 @@ -<%= sanitize_name(@issue.author_name) %> <%= 'created an issue:' %> <%= url_for(project_issue_url(@issue.project, @issue)) %> +<%= _('%{user} created an issue: %{issue_link}') % { user: sanitize_name(@issue.author_name), + issue_link: url_for(project_issue_url(@issue.project, @issue)) } %> <%= assignees_label(@issue) if @issue.assignees.any? %> diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml index 8fdba10e7a1..c8a0a6591a6 100644 --- a/app/views/notify/new_merge_request_email.html.haml +++ b/app/views/notify/new_merge_request_email.html.haml @@ -1,8 +1,6 @@ %p.details - = html_escape(_('%{userLinkStart}%{user}%{linkEnd} created a %{mrLinkStart}merge request%{linkEnd}:')) % {userLinkStart: "<a href=\"#{user_url(@merge_request.author)}\">".html_safe, - user: @merge_request.author_name, - mrLinkStart: "<a href=\"#{@target_url}\">".html_safe, - linkEnd: '</a>'.html_safe} + = html_escape(_('%{user} created a merge request: %{mr_link}')) % { user: link_to(@merge_request.author_name, user_url(@merge_request.author)), + mr_link: merge_request_reference_link(@merge_request) } %p .branch diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb index 6148af4890e..09e8ca36225 100644 --- a/app/views/notify/new_merge_request_email.text.erb +++ b/app/views/notify/new_merge_request_email.text.erb @@ -1,7 +1,9 @@ -<%= sanitize_name(@merge_request.author_name) %> <%= 'created a merge request:' %> <%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %> +<%= _('%{user} created a merge request: %{mr_link}') % { user: sanitize_name(@merge_request.author_name), + mr_link: url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) } +%> <%= merge_path_description(@merge_request, 'to') %> -<%= 'Author:' %> <%= @merge_request.author_name %> +<%= "#{_('Author')}: #{sanitize_name(@merge_request.author_name)}" %> <%= assignees_label(@merge_request) if @merge_request.assignees.any? %> <%= reviewers_label(@merge_request) if @merge_request.reviewers.any? %> <%= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter %> diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml index ca895972b71..24c25bc1ab2 100644 --- a/app/views/profiles/chat_names/_chat_name.html.haml +++ b/app/views/profiles/chat_names/_chat_name.html.haml @@ -1,5 +1,5 @@ -- service = chat_name.service -- project = service.project +- integration = chat_name.integration +- project = integration.project %tr %td %strong @@ -10,9 +10,9 @@ %td %strong - if can?(current_user, :admin_project, project) - = link_to service.title, edit_project_service_path(project, service) + = link_to integration.title, edit_project_service_path(project, integration) - else - = service.title + = integration.title %td = chat_name.team_domain %td diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml index 35335f3ef80..74b48115d0e 100644 --- a/app/views/profiles/keys/_form.html.haml +++ b/app/views/profiles/keys/_form.html.haml @@ -5,12 +5,12 @@ .form-group = f.label :key, s_('Profiles|Key'), class: 'label-bold' %p= _("Paste your public SSH key, which is usually contained in the file '~/.ssh/id_ed25519.pub' or '~/.ssh/id_rsa.pub' and begins with 'ssh-ed25519' or 'ssh-rsa'. Do not paste your private SSH key, as that can compromise your identity.") - = f.text_area :key, class: "form-control js-add-ssh-key-validation-input qa-key-public-key-field", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-ed25519 …" or "ssh-rsa …"') + = f.text_area :key, class: "form-control gl-form-input js-add-ssh-key-validation-input qa-key-public-key-field", rows: 8, required: true, placeholder: s_('Profiles|Typically starts with "ssh-ed25519 …" or "ssh-rsa …"') .form-row .col.form-group = f.label :title, _('Title'), class: 'label-bold' - = f.text_field :title, class: "form-control input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key') - %p.form-text.text-muted= s_('Profiles|Give your individual key a title.') + = f.text_field :title, class: "form-control gl-form-input input-lg qa-key-title-field", required: true, placeholder: s_('Profiles|e.g. My MacBook key') + %p.form-text.text-muted= s_('Profiles|Give your individual key a title. This will be publicly visible.') .col.form-group = f.label :expires_at, s_('Profiles|Expires at'), class: 'label-bold' diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index 4eb321050ad..178ed01c766 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -28,4 +28,4 @@ %span.key-created-at.gl-display-flex.gl-align-items-center - if key.can_delete? .gl-ml-3 - = render 'shared/ssh_keys/key_delete', html_class: "btn gl-button btn-icon btn-danger js-confirm-modal-button", button_data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) + = render 'shared/ssh_keys/key_delete', html_class: "btn gl-button btn-icon btn-default js-confirm-modal-button", button_data: ssh_key_delete_modal_data(key, path_to_key(key, is_admin)) diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml index efcd0bb621f..7780ffe0cb4 100644 --- a/app/views/profiles/passwords/new.html.haml +++ b/app/views/profiles/passwords/new.html.haml @@ -16,16 +16,16 @@ .col-sm-2.col-form-label = f.label :current_password, _('Current password') .col-sm-10 - = f.password_field :current_password, required: true, class: 'form-control gl-form-input' + = f.password_field :current_password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'current_password_field' } .form-group.row .col-sm-2.col-form-label = f.label :password, _('New password') .col-sm-10 - = f.password_field :password, required: true, class: 'form-control gl-form-input' + = f.password_field :password, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'new_password_field' } .form-group.row .col-sm-2.col-form-label = f.label :password_confirmation, _('Password confirmation') .col-sm-10 - = f.password_field :password_confirmation, required: true, class: 'form-control gl-form-input' + = f.password_field :password_confirmation, required: true, class: 'form-control gl-form-input', data: { qa_selector: 'confirm_password_field' } .form-actions - = f.submit _('Set new password'), class: 'gl-button btn btn-confirm' + = f.submit _('Set new password'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'set_new_password_button' } diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 535964028f4..0adad6b64a0 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -100,7 +100,7 @@ .form-group = f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold' = f.number_field :tab_width, - class: 'form-control', + class: 'form-control gl-form-input', min: Gitlab::TabWidth::MIN, max: Gitlab::TabWidth::MAX, required: true diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 15544fb9c45..c3ec2f7bab3 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -70,10 +70,9 @@ prepend: emoji_button, append: reset_message_button, placeholder: s_("Profiles|What's your status?") - - if Feature.enabled?(:set_user_availability_status, @user, default_enabled: :yaml) - .checkbox-icon-inline-wrapper - = status_form.check_box :availability, { data: { testid: "user-availability-checkbox" }, label: s_("Profiles|Busy"), wrapper_class: 'gl-mr-0 gl-font-weight-bold' }, availability["busy"], availability["not_set"] - .gl-text-gray-600.gl-ml-5= s_('Profiles|"Busy" will be shown next to your name') + .checkbox-icon-inline-wrapper + = status_form.check_box :availability, { data: { testid: "user-availability-checkbox" }, label: s_("Profiles|Busy"), wrapper_class: 'gl-mr-0 gl-font-weight-bold' }, availability["busy"], availability["not_set"] + .gl-text-gray-600.gl-ml-5= s_('Profiles|"Busy" will be shown next to your name') - if Feature.enabled?(:user_time_settings) .col-lg-12 %hr diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index a9134057777..71262f4bcb9 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -29,7 +29,7 @@ - register_2fa_token = _('We recommend cloud-based mobile authenticator apps such as Authy, Duo Mobile, and LastPass. They can restore access if you lose your hardware device.') = register_2fa_token.html_safe .row.gl-mb-3 - .col-md-4 + .col-md-4.gl-pt-2{ style: 'background: #fff' } = raw @qr_code .col-md-8 .account-well @@ -49,7 +49,7 @@ = @error .form-group = label_tag :pin_code, _('Pin code'), class: "label-bold" - = text_field_tag :pin_code, nil, class: "form-control", required: true, data: { qa_selector: 'pin_code_field' } + = text_field_tag :pin_code, nil, class: "form-control gl-form-input", required: true, data: { qa_selector: 'pin_code_field' } .gl-mt-3 = submit_tag _('Register with two-factor app'), class: 'gl-button btn btn-confirm', data: { qa_selector: 'register_2fa_app_button' } diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index db0f13843dd..c5a0b6a1428 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -11,4 +11,4 @@ .content_list.project-activity{ :"data-href" => activity_project_path(@project) } .loading - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/projects/_archived_notice.html.haml b/app/views/projects/_archived_notice.html.haml index dcece8ab42f..5489e41d37b 100644 --- a/app/views/projects/_archived_notice.html.haml +++ b/app/views/projects/_archived_notice.html.haml @@ -2,4 +2,4 @@ .text-warning.center.prepend-top-20 %p = sprite_icon('warning-solid') - = _('Archived project! Repository and other project resources are read only') + = _('Archived project! Repository and other project resources are read-only') diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml index 4b41231ba20..987ec74e4ba 100644 --- a/app/views/projects/_commit_button.html.haml +++ b/app/views/projects/_commit_button.html.haml @@ -1,7 +1,7 @@ -.form-actions +.form-actions.gl-display-flex = button_tag 'Commit changes', id: 'commit-changes', class: 'gl-button btn btn-confirm js-commit-button qa-commit-button' = link_to 'Cancel', cancel_path, - class: 'gl-button btn btn-default btn-cancel', data: {confirm: leave_edit_message} + class: 'gl-button btn btn-default gl-ml-3', data: {confirm: leave_edit_message} = render 'shared/projects/edit_information' diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 0369ee50c40..8642dc5fc8c 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -7,7 +7,7 @@ - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json") #tree-holder.tree-holder.clearfix - .nav-block + .nav-block.gl-display-flex.gl-align-items-center = render 'projects/tree/tree_header', tree: @tree #js-last-commit diff --git a/app/views/projects/_fork_suggestion.html.haml b/app/views/projects/_fork_suggestion.html.haml index 9888ce417f8..55e609c0ffb 100644 --- a/app/views/projects/_fork_suggestion.html.haml +++ b/app/views/projects/_fork_suggestion.html.haml @@ -1,10 +1,7 @@ +- message_base = s_("ForkSuggestion|You can’t %{edit_start}edit%{edit_end} files directly in this project. Fork this project and submit a merge request with your changes.").html_safe +- message = message_base.html_safe % { edit_start: '<span class="js-file-fork-suggestion-section-action">'.html_safe, edit_end: '</span>'.html_safe } .js-file-fork-suggestion-section.file-fork-suggestion.hidden - %span.file-fork-suggestion-note - You're not allowed to - %span.js-file-fork-suggestion-section-action - edit - files in this project directly. Please fork this project, - make your changes there, and submit a merge request. - = link_to 'Fork', nil, method: :post, class: 'js-fork-suggestion-button gl-button btn btn-grouped btn-confirm-secondary' + %span.file-fork-suggestion-note= message + = link_to s_('ForkSuggestion|Fork'), nil, method: :post, class: 'js-fork-suggestion-button gl-button btn btn-grouped btn-confirm-secondary' %button.js-cancel-fork-suggestion-button.gl-button.btn.btn-grouped{ type: 'button' } - Cancel + = s_('ForkSuggestion|Cancel') diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index b2380a3ba57..a70679dab5f 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -2,6 +2,7 @@ - show_auto_devops_callout = show_auto_devops_callout?(@project) - max_project_topic_length = 15 - emails_disabled = @project.emails_disabled? +- cache_enabled = Feature.enabled?(:cache_home_panel, @project, type: :development, default_enabled: :yaml) .project-home-panel.js-show-on-project-root.gl-my-5{ class: [("empty-project" if empty_repo)] } .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-sm-flex-direction-column.gl-mb-3 @@ -23,42 +24,45 @@ - if current_user %span.access-request-links.gl-ml-3 = render 'shared/members/access_request_links', source: @project - - if @project.tag_list.present? - %span.home-panel-topic-list.mt-2.w-100.d-inline-flex.gl-font-base.gl-font-weight-normal.gl-align-items-center - = sprite_icon('tag', css_class: 'icon gl-relative gl-mr-2') - - @project.topics_to_show.each do |topic| - - project_topics_classes = "badge badge-pill badge-secondary gl-mr-2" - - explore_project_topic_path = explore_projects_path(tag: topic) - - if topic.length > max_project_topic_length - %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' } - = topic.titleize - - else - %a{ class: project_topics_classes, href: explore_project_topic_path, itemprop: 'keywords' } - = topic.titleize + - if @project.tag_list.present? + = cache_if(cache_enabled, [@project, :tag_list], expires_in: 1.day) do + %span.home-panel-topic-list.mt-2.w-100.d-inline-flex.gl-font-base.gl-font-weight-normal.gl-align-items-center + = sprite_icon('tag', css_class: 'icon gl-relative gl-mr-2') - - if @project.has_extra_topics? - .text-nowrap.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.topics_not_shown.join(', ') : nil } - = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown } + - @project.topics_to_show.each do |topic| + - project_topics_classes = "badge badge-pill badge-secondary gl-mr-2" + - explore_project_topic_path = explore_projects_path(tag: topic) + - if topic.length > max_project_topic_length + %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' } + = topic.titleize + - else + %a{ class: project_topics_classes, href: explore_project_topic_path, itemprop: 'keywords' } + = topic.titleize + - if @project.has_extra_topics? + .text-nowrap.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.topics_not_shown.join(', ') : nil } + = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown } - .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-start.gl-flex-wrap.gl-mt-5 - - if current_user - .gl-display-flex.gl-align-items-start.gl-mr-3 - - if @notification_setting - .js-vue-notification-dropdown{ data: { button_size: "small", disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id } } + = cache_if(cache_enabled, [@project, :buttons, current_user, @notification_setting], expires_in: 1.day) do + .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-start.gl-flex-wrap.gl-mt-5 + - if current_user + .gl-display-flex.gl-align-items-start.gl-mr-3 + - if @notification_setting + .js-vue-notification-dropdown{ data: { button_size: "small", disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id } } - .count-buttons.gl-display-flex.gl-align-items-flex-start - = render 'projects/buttons/star' - = render 'projects/buttons/fork' + .count-buttons.gl-display-flex.gl-align-items-flex-start + = render 'projects/buttons/star' + = render 'projects/buttons/fork' - if can?(current_user, :download_code, @project) - %nav.project-stats - .nav-links.quick-links - - if @project.empty_repo? - = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors - - else - = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) + = cache_if(cache_enabled, [@project, :download_code], expires_in: 1.minute) do + %nav.project-stats + .nav-links.quick-links + - if @project.empty_repo? + = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors + - else + = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout) .home-panel-home-desc.mt-1 - if @project.description.present? @@ -80,11 +84,12 @@ = render_if_exists "projects/home_mirror" - if @project.badges.present? - .project-badges.mb-2 - - @project.badges.each do |badge| - %a.gl-mr-3{ href: badge.rendered_link_url(@project), - target: '_blank', - rel: 'noopener noreferrer' }> - %img.project-badge{ src: badge.rendered_image_url(@project), - 'aria-hidden': true, - alt: 'Project badge' }> + = cache_if(cache_enabled, [@project, :badges], expires_in: 1.day) do + .project-badges.mb-2 + - @project.badges.each do |badge| + %a.gl-mr-3{ href: badge.rendered_link_url(@project), + target: '_blank', + rel: 'noopener noreferrer' }> + %img.project-badge{ src: badge.rendered_image_url(@project), + 'aria-hidden': true, + alt: 'Project badge' }> diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index e6ded3ad912..c0fe788b56a 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -83,7 +83,7 @@ .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') } - = form_for @project, html: { class: 'new_project' } do |f| + = form_for @project, html: { class: 'new_project gl-show-field-errors' } do |f| %hr = render "shared/import_form", f: f = render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/_merge_request_merge_method_settings.html.haml index f55d840e14b..2d18285ba80 100644 --- a/app/views/projects/_merge_request_merge_method_settings.html.haml +++ b/app/views/projects/_merge_request_merge_method_settings.html.haml @@ -22,7 +22,7 @@ = s_('ProjectSettings|When there is a merge conflict, the user is given the option to rebase.') .form-check.mb-2 - = form.radio_button :merge_method, :ff, class: "js-merge-method-radio form-check-input", data: { qa_selector: 'merge_ff_radio_button' } + = form.radio_button :merge_method, :ff, class: "js-merge-method-radio form-check-input", data: { qa_selector: 'merge_ff_radio' } = label_tag :project_merge_method_ff, class: 'form-check-label' do = s_('ProjectSettings|Fast-forward merge') .text-secondary diff --git a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml index 12ab905479a..6e3c366da82 100644 --- a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml +++ b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml @@ -3,7 +3,7 @@ .form-group %b= s_('ProjectSettings|Merge suggestions') %p.text-secondary - - configure_the_commit_message_for_applied_suggestions_help_link_url = help_page_path('user/discussions/index.md', anchor: 'configure-the-commit-message-for-applied-suggestions') + - configure_the_commit_message_for_applied_suggestions_help_link_url = help_page_path('user/project/merge_requests/reviews/suggestions.md', anchor: 'configure-the-commit-message-for-applied-suggestions') - configure_the_commit_message_for_applied_suggestions_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: configure_the_commit_message_for_applied_suggestions_help_link_url } = s_('ProjectSettings|The commit message used when applying merge request suggestions. %{link_start}Learn more about suggestions.%{link_end}').html_safe % { link_start: configure_the_commit_message_for_applied_suggestions_help_link_start, link_end: '</a>'.html_safe } .mb-2 diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index 4695cd59f32..66fc313213a 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -8,7 +8,7 @@ .form-group.project-name.col-sm-12 = f.label :name, class: 'label-bold' do %span= _("Project name") - = f.text_field :name, placeholder: "My awesome project", class: "form-control input-lg", autofocus: true, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true } + = f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", autofocus: true, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true } .form-group.project-path.col-sm-6 = f.label :namespace_id, class: 'label-bold' do %span= s_("Project URL") @@ -33,7 +33,7 @@ .form-group.project-path.col-sm-6 = f.label :path, class: 'label-bold' do %span= _("Project slug") - = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", required: true, aria: { required: true } + = f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true } - if current_user.can_create_group? .form-text.text-muted - link_start_group_path = '<a href="%{path}">' % { path: new_group_path } @@ -43,7 +43,7 @@ .form-group = f.label :description, class: 'label-bold' do = s_('ProjectsNew|Project description %{tag_start}(optional)%{tag_end}').html_safe % { tag_start: '<span>'.html_safe, tag_end: '</span>'.html_safe } - = f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_description", track_value: "" } + = f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control gl-form-input", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_description", track_value: "" } = f.label :visibility_level, class: 'label-bold' do = s_('ProjectsNew|Visibility Level') diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 84f2d352bc9..e50b964a253 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -12,7 +12,7 @@ - if @code_navigation_path #js-code-navigation{ data: { code_navigation_path: @code_navigation_path, blob_path: blob.path, definition_path_prefix: project_blob_path(@project, @ref) } } - if Feature.enabled?(:refactor_blob_viewer, @project, default_enabled: :yaml) - #js-view-blob-app{ data: { blob_path: blob.path } } + #js-view-blob-app{ data: { blob_path: blob.path, project_path: @project.full_path } } .gl-spinner-container = loading_icon(size: 'md') - else diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 7d3a0c4a026..f2f753b4e86 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -3,7 +3,7 @@ - is_markdown = Gitlab::MarkupHelper.gitlab_markdown?(file_name) .file-holder-bottom-radius.file-holder.file.gl-mb-3 - .js-file-title.file-title.align-items-center.clearfix{ data: { current_action: action } } + .js-file-title.file-title.gl-display-flex.gl-align-items-center.clearfix{ data: { current_action: action } } .editor-ref.block-truncated.has-tooltip{ title: ref } = sprite_icon('fork', size: 12) = ref @@ -26,16 +26,18 @@ dismiss_key: @project.id, human_access: human_access } } - .file-buttons + .file-buttons.gl-display-flex.gl-align-items-center.gl-justify-content-end - if is_markdown = render 'shared/blob/markdown_buttons', show_fullscreen_button: false - = button_tag class: 'soft-wrap-toggle btn gl-button', type: 'button', tabindex: '-1' do - %span.no-wrap - = custom_icon('icon_no_wrap') - No wrap - %span.soft-wrap - = custom_icon('icon_soft_wrap') - Soft wrap + = button_tag class: 'soft-wrap-toggle btn gl-button btn-default', type: 'button', tabindex: '-1' do + .no-wrap + = sprite_icon('soft-unwrap', css_class: 'gl-button-icon') + %span.gl-button-text + No wrap + .soft-wrap + = sprite_icon('soft-wrap', css_class: 'gl-button-icon') + %span.gl-button-text + Soft wrap .file-editor.code .js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true } }< diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml index 24a4db010c8..a76e61bc3dd 100644 --- a/app/views/projects/blob/_template_selectors.html.haml +++ b/app/views/projects/blob/_template_selectors.html.haml @@ -11,8 +11,5 @@ = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector qa-metrics-dashboard-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project) } } ) #gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project), selected: params[:template] } } ) - - if experiment_enabled?(:ci_syntax_templates_b, subject: current_user) && @project.namespace.recent? - .gitlab-ci-syntax-yml-selector.js-gitlab-ci-syntax-yml-selector-wrap.js-template-selector-wrap.hidden - = dropdown_tag(_("Learn CI/CD syntax"), options: { toggle_class: 'js-gitlab-ci-syntax-yml-selector qa-gitlab-ci-syntax-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_syntax_ymls(@project) } } ) .dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-dockerfile-selector qa-dockerfile-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: dockerfile_names(@project) } } ) diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml index c42b54ec61d..28e33e3ac9b 100644 --- a/app/views/projects/blob/_upload.html.haml +++ b/app/views/projects/blob/_upload.html.haml @@ -21,7 +21,7 @@ .form-actions = button_tag class: 'btn gl-button btn-confirm btn-upload-file', id: 'submit-all', type: 'button' do - .spinner.spinner-sm.gl-mr-2.js-loading-icon.hidden + .gl-spinner.gl-mr-2.js-loading-icon.hidden = button_title = link_to _("Cancel"), '#', class: "btn gl-button btn-default btn-cancel", "data-dismiss" => "modal" diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index abfed450316..9f89981e7ca 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -1,5 +1,7 @@ - breadcrumb_title _("Repository") - page_title _("Edit"), @blob.path, @ref +- content_for :prefetch_asset_tags do + - webpack_preload_asset_tag('monaco') - if @conflict .gl-alert.gl-alert-danger.gl-mb-5.gl-mt-5 diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml index 8722819fe4f..2aeffa88c8f 100644 --- a/app/views/projects/blob/new.html.haml +++ b/app/views/projects/blob/new.html.haml @@ -1,16 +1,19 @@ - breadcrumb_title _("Repository") - page_title _("New File"), @path.presence, @ref -%h3.page-title.blob-new-page-title - New file +%h3.page-title.blob-new-page-title#js-code-quality-walkthrough + = _('New file') + .js-code-quality-walkthrough{ data: { step: 'commit_ci_file' } } .file-editor = form_tag(project_create_blob_path(@project, @id), method: :post, class: 'js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths(@project)) do = render 'projects/blob/editor', ref: @ref = render 'shared/new_commit_form', placeholder: "Add new file" + - if params[:code_quality_walkthrough] + = hidden_field_tag 'code_quality_walkthrough', 'true' = hidden_field_tag 'content', '', id: 'file-content' = render 'projects/commit_button', ref: @ref, - cancel_path: project_tree_path(@project, @id) + cancel_path: project_tree_path(@project, @id) - if should_suggest_gitlab_ci_yml? .js-suggest-gitlab-ci-yml-commit-changes{ data: { target: '#commit-changes', merge_request_path: params[:mr_path], diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index c66300aa947..1ba38808937 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -1,6 +1,8 @@ - breadcrumb_title "Repository" - page_title @blob.path, @ref - signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit, limit: 1) +- content_for :prefetch_asset_tags do + - webpack_preload_asset_tag('monaco', prefetch: true) .js-signature-container{ data: { 'signatures-path': signatures_path } } diff --git a/app/views/projects/blob/viewers/_changelog.html.haml b/app/views/projects/blob/viewers/_changelog.html.haml index 80ead53beff..cac858c1444 100644 --- a/app/views/projects/blob/viewers/_changelog.html.haml +++ b/app/views/projects/blob/viewers/_changelog.html.haml @@ -1,4 +1,3 @@ = sprite_icon('history', css_class: 'gl-mr-1 gl-vertical-align-text-bottom') = succeed '.' do - To find the state of this project's repository at the time of any of these versions, check out - = link_to "the tags", project_tags_path(viewer.project) + = _("To find the state of this project's repository at the time of any of these versions, check out %{link_start}the tags%{link_end}.").html_safe % { link_start: "<a href='#{project_tags_path(viewer.project)}'>".html_safe, link_end: "</a>".html_safe } diff --git a/app/views/projects/blob/viewers/_contributing.html.haml b/app/views/projects/blob/viewers/_contributing.html.haml index 18559e2908f..eac8c17b7ff 100644 --- a/app/views/projects/blob/viewers/_contributing.html.haml +++ b/app/views/projects/blob/viewers/_contributing.html.haml @@ -1,9 +1,9 @@ = sprite_icon('book') -After you've reviewed these contribution guidelines, you'll be all set to += _("After you've reviewed these contribution guidelines, you'll be all set to") - options = contribution_options(viewer.project) - if options.any? = succeed '.' do = Gitlab::Utils.to_exclusive_sentence(options).html_safe - else - contribute to this project. + = _("contribute to this project.") diff --git a/app/views/projects/blob/viewers/_download.html.haml b/app/views/projects/blob/viewers/_download.html.haml index fda4b9c92cd..61f64177be8 100644 --- a/app/views/projects/blob/viewers/_download.html.haml +++ b/app/views/projects/blob/viewers/_download.html.haml @@ -4,4 +4,4 @@ %h1.light = sprite_icon('download') %h4 - Download (#{number_to_human_size(viewer.blob.raw_size)}) + = _('Download (%{size})').html_safe % { size: number_to_human_size(viewer.blob.raw_size) } diff --git a/app/views/projects/blob/viewers/_license.html.haml b/app/views/projects/blob/viewers/_license.html.haml index d2bd90a898a..320d7dd4b9f 100644 --- a/app/views/projects/blob/viewers/_license.html.haml +++ b/app/views/projects/blob/viewers/_license.html.haml @@ -1,8 +1,6 @@ - license = viewer.license = sprite_icon('scale') -This project is licensed under the -= succeed '.' do - %strong= license.name += _("This project is licensed under the %{strong_start}%{license_name}%{strong_end}.").html_safe % { license_name: license.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } -= link_to 'Learn more', license.url, target: '_blank', rel: 'noopener noreferrer' += link_to _('Learn more'), license.url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml index 86f59146cda..e06ff4edf71 100644 --- a/app/views/projects/blob/viewers/_readme.html.haml +++ b/app/views/projects/blob/viewers/_readme.html.haml @@ -1,4 +1,4 @@ = sprite_icon('information-o', css_class: 'gl-vertical-align-middle! gl-mr-2') = succeed '.' do - To learn more about this project, read - = link_to "the wiki", wiki_path(viewer.project.wiki) + - link_to_wiki = link_to(_("the wiki"), wiki_path(viewer.project.wiki)) + = _("To learn more about this project, read %{link_to_wiki}.").html_safe % { link_to_wiki: link_to_wiki } diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 3071e5ea5f8..2f89a3f62ed 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -4,9 +4,9 @@ - archive_prefix = "#{project.path}-#{ref.tr('/', '-')}" .project-action-button.dropdown.inline> %button.gl-button.btn.btn-default.has-tooltip{ title: s_('DownloadSource|Download'), 'data-toggle' => 'dropdown', 'aria-label' => s_('DownloadSource|Download'), 'data-display' => 'static', data: { qa_selector: 'download_source_code_button' } } - = sprite_icon('download') + = sprite_icon('download', css_class: 'gl-icon') %span.sr-only= _('Select Archive Format') - = sprite_icon("chevron-down") + = sprite_icon('chevron-down', css_class: 'gl-icon') .dropdown-menu.dropdown-menu-right{ role: 'menu' } %section %h5.m-0.dropdown-bold-header= _('Download source code') diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index 5effa5a9e92..12ce4667e1a 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -28,14 +28,14 @@ %li.dropdown-header= _('This repository') - if can_push_code - %li.qa-new-file-option= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master') + %li.qa-new-file-option= link_to _('New file'), project_new_blob_path(@project, @project.default_branch_or_main) - unless @project.empty_repo? %li= link_to _('New branch'), new_project_branch_path(@project) %li= link_to _('New tag'), new_project_tag_path(@project) - elsif can_collaborate_with_project?(@project) - %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master') + %li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch_or_main) - elsif create_mr_from_new_fork - - continue_params = { to: project_new_blob_path(@project, @project.default_branch || 'master'), + - continue_params = { to: project_new_blob_path(@project, @project.default_branch_or_main), notice: edit_in_new_fork_notice, notice_now: edit_in_new_fork_notice_now } - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params) diff --git a/app/views/projects/buttons/_remove_tag.html.haml b/app/views/projects/buttons/_remove_tag.html.haml index cdf6336a259..58af0d91f30 100644 --- a/app/views/projects/buttons/_remove_tag.html.haml +++ b/app/views/projects/buttons/_remove_tag.html.haml @@ -2,5 +2,5 @@ - tag = local_assigns.fetch(:tag, nil) - return unless project && tag -%button{ type: "button", class: "js-remove-tag js-confirm-modal-button gl-button btn btn-danger btn-icon has-tooltip gl-ml-3 #{protected_tag?(project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), data: { container: 'body', path: project_tag_path(@project, tag.name), modal_attributes: delete_tag_modal_attributes(tag.name) } } - = sprite_icon("remove") +%button{ type: "button", class: "js-remove-tag js-confirm-modal-button gl-button btn btn-default btn-icon has-tooltip gl-ml-3\! #{protected_tag?(project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), data: { container: 'body', path: project_tag_path(@project, tag.name), modal_attributes: delete_tag_modal_attributes(tag.name) } } + = sprite_icon('remove', css_class: 'gl-icon') diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml index eb588e150f7..674765e9f89 100644 --- a/app/views/projects/ci/pipeline_editor/show.html.haml +++ b/app/views/projects/ci/pipeline_editor/show.html.haml @@ -1,3 +1,5 @@ - page_title s_('Pipelines|Pipeline Editor') +- content_for :prefetch_asset_tags do + - webpack_preload_asset_tag('monaco') #js-pipeline-editor{ data: js_pipeline_editor_data(@project) } diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 1b28136e82c..67007aa7448 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -37,13 +37,13 @@ - @commit.parents.each do |parent| = link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha" .commit-info.branches - .spinner.vertical-align-middle + .gl-spinner.vertical-align-middle .well-segment.merge-request-info .icon-container = custom_icon('mr_bold') %span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) } - .spinner.vertical-align-middle + .gl-spinner.vertical-align-middle - if can?(current_user, :read_pipeline, @last_pipeline) .well-segment.pipeline-info diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml index 7f52e6ed7eb..16df743475d 100644 --- a/app/views/projects/commit/_pipelines_list.haml +++ b/app/views/projects/commit/_pipelines_list.haml @@ -1,7 +1,11 @@ - disable_initialization = local_assigns.fetch(:disable_initialization, false) +- artifacts_endpoint_placeholder = ':pipeline_artifacts_id' + #commit-pipeline-table-view{ data: { disable_initialization: disable_initialization, endpoint: endpoint, "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'), "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'), "project-id": @project.id, + "artifacts-endpoint" => downloadable_artifacts_project_pipeline_path(@project, artifacts_endpoint_placeholder, format: :json), + "artifacts-endpoint-placeholder" => artifacts_endpoint_placeholder, } } diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index 5652b503a6d..c3fdfeb6f4e 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -12,7 +12,12 @@ .container-fluid{ class: [limited_container_width, container_class] } = render "commit_box" = render "ci_menu" - = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-commit", paginate_diffs: true + = render "projects/diffs/diffs", + diffs: @diffs, + environment: @environment, + diff_page_context: "is-commit", + paginate_diffs: true, + paginate_diffs_per_page: Projects::CommitController::COMMIT_DIFFS_PER_PAGE .limited-width-notes = render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index ceb312450be..bc0d14743b9 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -3,22 +3,24 @@ - `assets/javascripts/diffs/components/commit_item.vue` EXCEPTION WARNING - see above `.vue` file for de-sync drift --#----------------------------------------------------------------- -- view_details = local_assigns.fetch(:view_details, false) -- merge_request = local_assigns.fetch(:merge_request, nil) -- project = local_assigns.fetch(:project) { merge_request&.project } -- ref = local_assigns.fetch(:ref) { merge_request&.source_branch } -- commit = commit.present(current_user: current_user) -- commit_status = commit.status_for(ref) -- collapsible = local_assigns.fetch(:collapsible, true) -- link_data_attrs = local_assigns.fetch(:link_data_attrs, {}) - -- link = commit_path(project, commit, merge_request: merge_request) + WARNING: When introducing new content here, please consider what + changes may need to be made in the cache keys used to + wrap this view, found in + CommitsHelper#commit_partial_cache_key +-#----------------------------------------------------------------- +- view_details = local_assigns.fetch(:view_details, false) +- merge_request = local_assigns.fetch(:merge_request, nil) +- project = local_assigns.fetch(:project) { merge_request&.project } +- ref = local_assigns.fetch(:ref) { merge_request&.source_branch } +- commit = commit.present(current_user: current_user) +- commit_status = commit.status_for(ref) +- collapsible = local_assigns.fetch(:collapsible, true) +- link_data_attrs = local_assigns.fetch(:link_data_attrs, {}) +- link = commit_path(project, commit, merge_request: merge_request) - show_project_name = local_assigns.fetch(:show_project_name, false) %li{ class: ["commit flex-row", ("js-toggle-container" if collapsible)], id: "commit-#{commit.short_id}" } - .avatar-cell.d-none.d-sm-block = author_avatar(commit, size: 40, has_tooltip: false) diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 9e2dca3ad71..e6c9a7166a9 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -3,8 +3,8 @@ - ref = local_assigns.fetch(:ref) { merge_request&.source_branch } - can_update_merge_request = can?(current_user, :update_merge_request, @merge_request) -- commits = @commits -- context_commits = @context_commits +- commits = @commits&.map { |commit| commit.present(current_user: current_user) } +- context_commits = @context_commits&.map { |commit| commit.present(current_user: current_user) } - hidden = @hidden_commit_count - commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, daily_commits| @@ -14,7 +14,10 @@ %li.commits-row{ data: { day: day } } %ul.content-list.commit-list.flex-list - = render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request } + - if Feature.enabled?(:cached_commits, project, default_enabled: :yaml) + = render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request }, cached: -> (commit) { commit_partial_cache_key(commit, ref: ref, merge_request: merge_request, request: request) } + - else + = render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request } - if context_commits.present? %li.commit-header.js-commit-header @@ -25,7 +28,10 @@ %li.commits-row %ul.content-list.commit-list.flex-list - = render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request } + - if Feature.enabled?(:cached_commits, project, default_enabled: :yaml) + = render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request }, cached: -> (commit) { commit_partial_cache_key(commit, ref: ref, merge_request: merge_request, request: request) } + - else + = render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request } - if hidden > 0 %li.gl-alert.gl-alert-warning diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index e3ab184ec6f..426d022da26 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -4,11 +4,11 @@ %h3.page-title = _("Compare Git revisions") .sub-header-block - - example_master = capture do - %code.ref-name master + - example_branch = capture do + %code.ref-name= @project.default_branch_or_main - example_sha = capture do %code.ref-name 4eedf23 - = html_escape(_("Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request.")) % { master: example_master.html_safe, sha: example_sha.html_safe } + = html_escape(_("Choose a branch/tag (e.g. %{branch}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request.")) % { branch: example_branch.html_safe, sha: example_sha.html_safe } %br = html_escape(_("Changes are shown as if the %{b_open}source%{b_close} revision was being merged into the %{b_open}target%{b_close} revision.")) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe } diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml index 9e9c271e7be..1fc067b6be1 100644 --- a/app/views/projects/compare/show.html.haml +++ b/app/views/projects/compare/show.html.haml @@ -6,8 +6,15 @@ #js-compare-selector{ data: project_compare_selector_data(@project, @merge_request, params) } - if @commits.present? - = render "projects/commits/commit_list" - = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-compare" + -# Only show commit list in the first page + - hide_commit_list = params[:page].present? && params[:page] != '1' + = render "projects/commits/commit_list" unless hide_commit_list + = render "projects/diffs/diffs", + diffs: @diffs, + environment: @environment, + diff_page_context: "is-compare", + paginate_diffs: true, + paginate_diffs_per_page: Projects::CompareController::COMMIT_DIFFS_PER_PAGE - else .card.bg-light .center diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 1c7a9ffe0bb..bb2682bb7c0 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -4,7 +4,8 @@ - diff_page_context = local_assigns.fetch(:diff_page_context, nil) - load_diff_files_async = Feature.enabled?(:async_commit_diff_files, @project) && diff_page_context == "is-commit" - paginate_diffs = local_assigns.fetch(:paginate_diffs, false) && !load_diff_files_async -- diff_files = conditionally_paginate_diff_files(diffs, paginate: paginate_diffs) +- paginate_diffs_per_page = local_assigns.fetch(:paginate_diffs_per_page, nil) +- diff_files = conditionally_paginate_diff_files(diffs, paginate: paginate_diffs, per: paginate_diffs_per_page) .content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed .files-changed-inner @@ -33,7 +34,7 @@ - url = url_for(safe_params.merge(action: 'diff_files')) .js-diffs-batch{ data: { diff_files_path: url } } .text-center - %span.spinner.spinner-md + %span.gl-spinner.gl-spinner-md - else = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: diff_page_context } diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index ecaf3467cd2..187fe608a68 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -106,7 +106,7 @@ .save-project-loader.hide .center %h2 - .spinner.spinner-md.align-text-bottom + .gl-spinner.gl-spinner-md.align-text-bottom = _('Saving project.') %p= _('Please wait a moment, this page will automatically refresh when ready.') diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 171222368d6..b76f6b27aa8 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -1,5 +1,5 @@ - @content_class = "limit-container-width" unless fluid_layout -- default_branch_name = @project.default_branch_or_master +- default_branch_name = @project.default_branch_or_main - @skip_current_level_breadcrumb = true = render partial: 'flash_messages', locals: { project: @project } diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 06a2ed46805..0136184f80d 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -7,4 +7,4 @@ "new-environment-path" => new_project_environment_path(@project), "help-page-path" => help_page_path("ci/environments/index.md"), "project-path" => @project.full_path, - "default-branch-name" => @project.default_branch_or_master } } + "default-branch-name" => @project.default_branch_or_main } } diff --git a/app/views/projects/feature_flags/edit.html.haml b/app/views/projects/feature_flags/edit.html.haml index 028595aba0b..1549f5cf6d6 100644 --- a/app/views/projects/feature_flags/edit.html.haml +++ b/app/views/projects/feature_flags/edit.html.haml @@ -12,5 +12,5 @@ user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION, show_user_callout: show_feature_flags_new_version?.to_s, strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'), - environments_scope_docs_path: help_page_path('ci/environments', anchor: 'scoping-environments-with-specs'), + environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scoping-environments-with-specs'), feature_flag_issues_endpoint: feature_flag_issues_links_endpoint(@project, @feature_flag, current_user) } } diff --git a/app/views/projects/feature_flags/new.html.haml b/app/views/projects/feature_flags/new.html.haml index 3bad1d9773c..bc52f52ecf7 100644 --- a/app/views/projects/feature_flags/new.html.haml +++ b/app/views/projects/feature_flags/new.html.haml @@ -10,5 +10,5 @@ user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION, show_user_callout: show_feature_flags_new_version?.to_s, strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'), - environments_scope_docs_path: help_page_path('ci/environments', anchor: 'scoping-environments-with-specs'), + environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'scoping-environments-with-specs'), project_id: @project.id } } diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml index a8a4eef65b3..ee4dbf5c05c 100644 --- a/app/views/projects/hook_logs/_index.html.haml +++ b/app/views/projects/hook_logs/_index.html.haml @@ -4,7 +4,7 @@ Recent Deliveries %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong. .col-lg-9 - - if hook_logs.any? + - if hook_logs.present? %table.table %thead %tr diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 838b4538cad..9c01d93f7d0 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -3,7 +3,9 @@ .issuable-info-container - if @can_bulk_update .issue-check.hidden - = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected-issuable" + - checkbox_id = dom_id(issue, "selected") + %label.gl-sr-only{ for: checkbox_id }= issue.title + = check_box_tag checkbox_id, nil, false, 'data-id' => issue.id, class: "selected-issuable" .issuable-main-info .issue-title.title %span.issue-title-text.js-onboarding-issue-item{ dir: "auto" } diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml index ef602da72e5..e4d072a9472 100644 --- a/app/views/projects/issues/_issues.html.haml +++ b/app/views/projects/issues/_issues.html.haml @@ -1,4 +1,5 @@ - is_project_overview = local_assigns.fetch(:is_project_overview, false) += render 'shared/alerts/positioning_disabled' - if Feature.enabled?(:vue_issuables_list, @project) && !is_project_overview - data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id))) @@ -15,7 +16,7 @@ 'scoped-labels-available': scoped_labels_available?(@project).to_json } } - else - empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues') - %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') } + %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class } = render partial: "projects/issues/issue", collection: @issues - if @issues.blank? = render empty_state_path diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 45b2f86c03d..07fec195899 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -13,13 +13,13 @@ .create-mr-dropdown-wrap.d-inline-block.full-width-mobile.js-create-mr{ data: { project_path: @project.full_path, project_id: @project.id, can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path, is_confidential: can_create_confidential_merge_request?.to_s } } .btn-group.unavailable %button.gl-button.btn{ type: 'button', disabled: 'disabled' } - .spinner.align-text-bottom.gl-button-icon.hide + .gl-spinner.align-text-bottom.gl-button-icon.hide %span.text Checking branch availability… .btn-group.available.hidden %button.gl-button.btn.js-create-merge-request.btn-confirm{ type: 'button', data: { action: data_action } } - .spinner.js-spinner.gl-mr-2.gl-display-none + .gl-spinner.js-spinner.gl-mr-2.gl-display-none = value %button.gl-button.btn.btn-confirm.btn-icon.dropdown-toggle.create-merge-request-dropdown-toggle.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } } diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index 9b043ea3c47..3e8442eee86 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -29,7 +29,7 @@ .issues-holder = render 'issues' - if new_issue_email - .issuable-footer.text-center + .gl-text-center.gl-pt-5.gl-pb-7 .js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } } - else - new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project) diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml index f2aab3d9394..1da3881c104 100644 --- a/app/views/projects/jobs/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -2,7 +2,7 @@ - add_page_specific_style 'page_bundles/ci_status' - if Feature.enabled?(:jobs_table_vue, @project, default_enabled: :yaml) - #js-jobs-table{ data: { full_path: @project.full_path, job_counts: job_counts.to_json, job_statuses: job_statuses.to_json } } + #js-jobs-table{ data: { full_path: @project.full_path, job_counts: job_counts.to_json, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } } - else .top-area - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) } diff --git a/app/views/projects/learn_gitlab/index.html.haml b/app/views/projects/learn_gitlab/index.html.haml index 94023b21aab..4935b72d3fa 100644 --- a/app/views/projects/learn_gitlab/index.html.haml +++ b/app/views/projects/learn_gitlab/index.html.haml @@ -2,4 +2,4 @@ - page_title _("Learn GitLab") - add_page_specific_style 'page_bundles/learn_gitlab' -#js-learn-gitlab-app{ data: { actions: onboarding_actions_data(@project).to_json } } +#js-learn-gitlab-app{ data: { actions: onboarding_actions_data(@project).to_json, sections: onboarding_sections_data.to_json } } diff --git a/app/views/projects/merge_requests/_description.html.haml b/app/views/projects/merge_requests/_description.html.haml index c20479662dd..1dd4cc6495c 100644 --- a/app/views/projects/merge_requests/_description.html.haml +++ b/app/views/projects/merge_requests/_description.html.haml @@ -1,6 +1,6 @@ %div - if @merge_request.description.present? - .description.qa-description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' } + .description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' , data: { qa_selector: 'description_content' } } .md = markdown_field(@merge_request, :description) %textarea.hidden.js-task-list-field{ data: { value: @merge_request.description } } diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index 6c0fc9575fc..b70bc740175 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -1,7 +1,9 @@ %li{ id: dom_id(merge_request), class: mr_css_classes(merge_request), data: { labels: merge_request.label_ids, id: merge_request.id } } - if @can_bulk_update .issue-check.hidden - = check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected-issuable" + - checkbox_id = dom_id(merge_request, "selected") + %label.gl-sr-only{ for: checkbox_id }= merge_request.title + = check_box_tag checkbox_id, nil, false, 'data-id' => merge_request.id, class: "selected-issuable" .issuable-info-container .issuable-main-info diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml index c38cf62b36c..916b841e350 100644 --- a/app/views/projects/merge_requests/_mr_box.html.haml +++ b/app/views/projects/merge_requests/_mr_box.html.haml @@ -1,3 +1,3 @@ .detail-page-description.py-2 - %h2.title.qa-title.mb-0 + %h2.title.mb-0{ data: { qa_selector: 'title_content' } } = markdown_field(@merge_request, :title) diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 26d8e571973..e42032fef66 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -1,53 +1,51 @@ - @no_breadcrumb_border = true - can_update_merge_request = can?(current_user, :update_merge_request, @merge_request) - can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request) -- state_human_name, state_icon_name = state_name_with_icon(@merge_request) - are_close_and_open_buttons_hidden = merge_request_button_hidden?(@merge_request, true) && merge_request_button_hidden?(@merge_request, false) +- cache_key = [@project, @merge_request, can_update_merge_request, can_reopen_merge_request, are_close_and_open_buttons_hidden] -- if @merge_request.closed_or_merged_without_fork? - .gl-alert.gl-alert-danger.gl-mb-5 - = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - .gl-alert-body - The source project of this merge request has been removed. += cache_if(Feature.enabled?(:cached_mr_title, @project, default_enabled: :yaml), cache_key, expires_in: 1.day) do + - if @merge_request.closed_or_merged_without_fork? + .gl-alert.gl-alert-danger.gl-mb-5 + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + The source project of this merge request has been removed. -.detail-page-header.border-bottom-0.pt-0.pb-0 - .detail-page-header-body - .issuable-status-box.status-box.js-mr-status-box{ class: status_box_class(@merge_request), data: { state: @merge_request.state } } - = sprite_icon(state_icon_name, css_class: 'gl-display-block gl-sm-display-none!') - %span.gl-display-none.gl-sm-display-block - = state_human_name + .detail-page-header.border-bottom-0.pt-0.pb-0 + .detail-page-header-body + = render "shared/issuable/status_box", issuable: @merge_request - .issuable-meta - #js-issuable-header-warnings - = issuable_meta(@merge_request, @project) + .issuable-meta + #js-issuable-header-warnings + = issuable_meta(@merge_request, @project) - %a.gl-button.btn.btn-default.btn-icon.float-right.d-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } - = sprite_icon('chevron-double-lg-left') + %a.gl-button.btn.btn-default.btn-icon.float-right.d-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } + = sprite_icon('chevron-double-lg-left') - .detail-page-header-actions.js-issuable-actions - .clearfix.dropdown - %button.gl-button.btn.btn-default.float-left.gl-md-display-none.gl-w-full{ type: "button", data: { toggle: "dropdown" } } - Options - = sprite_icon('chevron-down', css_class: 'gl-text-gray-500') - .dropdown-menu.dropdown-menu-right - %ul - - if can_update_merge_request - %li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - - if @merge_request.opened? - %li - = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_merge_request_path(@merge_request), method: :put, class: "js-draft-toggle-button" - %li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] } - = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request' - - if can_reopen_merge_request - %li{ class: merge_request_button_visibility(@merge_request, false) } - = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, title: 'Reopen merge request' - - unless @merge_request.merged? || current_user == @merge_request.author - %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) + .detail-page-header-actions.js-issuable-actions + .clearfix.dropdown + %button.gl-button.btn.btn-default.float-left.gl-md-display-none.gl-w-full{ type: "button", data: { toggle: "dropdown" } } + Options + = sprite_icon('chevron-down', css_class: 'gl-text-gray-500') + .dropdown-menu.dropdown-menu-right + %ul + - if can_update_merge_request + %li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + - if @merge_request.opened? + %li + = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_merge_request_path(@merge_request), method: :put, class: "js-draft-toggle-button" + %li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] } + = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request' + - if can_reopen_merge_request + %li{ class: merge_request_button_visibility(@merge_request, false) } + = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, title: 'Reopen merge request' + - unless @merge_request.merged? || current_user == @merge_request.author + %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) - - if can_update_merge_request - = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-default btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" } + - if can_update_merge_request + = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-default btn-grouped js-issuable-edit", data: { qa_selector: "edit_button" } - - if can_update_merge_request && !are_close_and_open_buttons_hidden - = render 'projects/merge_requests/close_reopen_draft_report_toggle' - - elsif !@merge_request.merged? - = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-md-display-block gl-button btn btn-default float-right gl-ml-3', title: _('Report abuse') + - if can_update_merge_request && !are_close_and_open_buttons_hidden + = render 'projects/merge_requests/close_reopen_draft_report_toggle' + - elsif !@merge_request.merged? + = link_to _('Report abuse'), new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)), class: 'gl-display-none gl-md-display-block gl-button btn btn-default float-right gl-ml-3', title: _('Report abuse') diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 6e6046eba14..606442d71a9 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -1,11 +1,15 @@ +- artifacts_endpoint_placeholder = ':pipeline_artifacts_id' + = javascript_tag do :plain window.gl = window.gl || {}; window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)} + window.gl.mrWidgetData.artifacts_endpoint = '#{downloadable_artifacts_project_pipeline_path(@project, artifacts_endpoint_placeholder, format: :json)}'; + window.gl.mrWidgetData.artifacts_endpoint_placeholder = '#{artifacts_endpoint_placeholder}'; window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}'; window.gl.mrWidgetData.ci_troubleshooting_docs_path = '#{help_page_path('ci/troubleshooting.md')}'; - window.gl.mrWidgetData.mr_troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}'; + window.gl.mrWidgetData.mr_troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviews/index.md', anchor: 'troubleshooting')}'; window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}'; window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests')}'; window.gl.mrWidgetData.license_compliance_docs_path = '#{help_page_path('user/compliance/license_compliance/index.md', anchor: 'policies')}'; diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index 7082bf4b8b0..b99714c1794 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -30,7 +30,7 @@ = dropdown_loading .card-footer .text-center - .js-source-loading.mt-1.spinner.spinner-sm + .js-source-loading.mt-1.gl-spinner %ul.list-unstyled.mr_source_commit .col-lg-6 @@ -59,7 +59,7 @@ = dropdown_loading .card-footer .text-center - .js-target-loading.mt-1.spinner.spinner-sm + .js-target-loading.mt-1.gl-spinner %ul.list-unstyled.mr_target_commit - if @merge_request.errors.any? diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index a8facf1c6fd..7e1ca19d9b6 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -26,16 +26,16 @@ %li.commits-tab.new-tab = link_to url_for(safe_params), data: {target: 'div#commits', action: 'new', toggle: 'tabvue'} do Commits - %span.badge.badge-pill= @total_commit_count + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @total_commit_count - if @pipelines.any? %li.builds-tab = link_to url_for(safe_params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tabvue'} do Pipelines - %span.badge.badge-pill= @pipelines.size + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @pipelines.size %li.diffs-tab = link_to url_for(safe_params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tabvue', qa_selector: 'diffs_tab'} do Changes - %span.badge.badge-pill= @merge_request.diff_size + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @merge_request.diff_size #diff-notes-app.tab-content #new.commits.tab-pane.active @@ -48,4 +48,4 @@ .mr-loading-status .loading.hide - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 22d78418c5b..289f88c9705 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -22,7 +22,7 @@ .merge-requests-holder = render 'merge_requests' - if new_merge_request_email - .issuable-footer.text-center + .gl-text-center.gl-pt-5.gl-pb-7 .js-issueable-by-email{ data: { initial_email: new_merge_request_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } } - else = render 'shared/empty_states/merge_requests', button_path: new_merge_request_path diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 416cb932ec9..49f2795538c 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -5,7 +5,7 @@ - page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge requests") - page_description @merge_request.description_html - page_card_attributes @merge_request.card_attributes -- suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes') +- suggest_changes_help_path = help_page_path('user/project/merge_requests/reviews/suggestions.md') - number_of_pipelines = @pipelines.size - mr_action = j(params[:tab].presence || 'show') - add_page_specific_style 'page_bundles/merge_requests' @@ -40,7 +40,7 @@ = render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do = tab_link_for @merge_request, :diffs do = _("Changes") - %span.badge.badge-pill.gl-badge.badge-muted.sm= @merge_request.diff_size + %span.badge.badge-pill.gl-badge.badge-muted.sm= @diffs_count .d-flex.flex-wrap.align-items-center.justify-content-lg-end #js-vue-discussion-counter @@ -85,7 +85,7 @@ .mr-loading-status .loading.hide - .spinner.spinner-md + .gl-spinner.gl-spinner-md = render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees, reviewers: @merge_request.reviewers, source_branch: @merge_request.source_branch diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index 56906eb6e66..dfb9defb91c 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -7,13 +7,17 @@ .col-form-label.col-sm-2 = f.label :title, _('Title') .col-sm-10 - = f.text_field :title, maxlength: 255, class: 'form-control', data: { qa_selector: 'milestone_title_field' }, required: true, autofocus: true + = f.text_field :title, maxlength: 255, class: 'form-control gl-form-input', data: { qa_selector: 'milestone_title_field' }, required: true, autofocus: true .form-group.row.milestone-description .col-form-label.col-sm-2 = f.label :description, _('Description') .col-sm-10 = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project) } do - = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', qa_selector: 'milestone_description_field', placeholder: _('Write milestone description...') + = render 'shared/zen', f: f, attr: :description, + classes: 'note-textarea', + qa_selector: 'milestone_description_field', + supports_autocomplete: true, + placeholder: _('Write milestone description...') = render 'shared/notes/hints' .clearfix .error-alert diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 2185df3a994..5a6c2c5faaf 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -4,6 +4,8 @@ - page_description @milestone.description_html - add_page_specific_style 'page_bundles/milestone' +- add_page_startup_api_call milestone_tab_path(@milestone, 'issues', show_project_name: false) + = render 'shared/milestones/header', milestone: @milestone = render 'shared/milestones/description', milestone: @milestone diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml index 4e3cd609d75..4411bc474b8 100644 --- a/app/views/projects/mirrors/_ssh_host_keys.html.haml +++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml @@ -4,7 +4,7 @@ .form-group.js-ssh-host-keys-section{ class: ('collapse' unless mirror.ssh_mirror_url?) } %button.btn.gl-button.btn-inverted.btn-secondary.inline.js-detect-host-keys.gl-mr-3{ type: 'button', data: { qa_selector: 'detect_host_keys' } } - .js-spinner.d-none.spinner.mr-1 + .js-spinner.d-none.gl-spinner.mr-1 = _('Detect host keys') .fingerprint-ssh-info.js-fingerprint-ssh-info.gl-mt-3.gl-mb-3{ class: ('collapse' unless mirror.ssh_mirror_url?) } %label.label-bold diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 3cff85a4979..4cabb930433 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -17,4 +17,4 @@ - if @commit .network-graph.gl-bg-white.gl-overflow-scroll.gl-overflow-x-hidden{ data: { url: @url, commit_url: @commit_url, ref: @ref, commit_id: @commit.id } } .text-center.gl-mt-3 - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 059d6eb28c5..c62853145b6 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -2,80 +2,38 @@ - @hide_top_links = true - page_title _('New Project') - header_title _("Projects"), dashboard_projects_path -- active_tab = local_assigns.fetch(:active_tab, 'blank') +- add_page_specific_style 'page_bundles/new_namespace' .project-edit-container.gl-mt-5 .project-edit-errors = render 'projects/errors' - .js-experiment-new-project-creation{ data: { is_ci_cd_available: (ci_cd_projects_available? if Gitlab.ee?), has_errors: @project.errors.any?, new_project_guidelines: brand_new_project_guidelines, push_to_create_project_command: push_to_create_project_command, working_with_projects_help_path: help_page_path("user/project/working_with_projects") } } + .js-new-project-creation{ data: { is_ci_cd_available: (ci_cd_projects_available? if Gitlab.ee?).to_s, has_errors: @project.errors.any?.to_s, new_project_guidelines: brand_new_project_guidelines, push_to_create_project_command: push_to_create_project_command, working_with_projects_help_path: help_page_path("user/project/working_with_projects") } } .row{ 'v-cloak': true } - .col-lg-3.profile-settings-sidebar - %h4.gl-mt-0 - = _('New project') - %p - - among_other_things_link = link_to _('among other things'), help_page_path("user/project/index.md", anchor: "project-features"), target: '_blank' - = _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link } - %p - = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.') - = render_if_exists 'projects/new_ci_cd_banner_external_repo' - %p - - pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/index", anchor: "getting-started"), target: '_blank' - = _('Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}.').html_safe % { pages_getting_started_guide: pages_getting_started_guide } - .md - = brand_new_project_guidelines - %p - %strong= _("Tip:") - = _("You can also create a project from the command line.") - - .col-lg-9.js-toggle-container - %ul.nav.nav-tabs.nav-links.gitlab-tabs{ role: 'tablist' } - %li.nav-item{ role: 'presentation' } - %a.nav-link.active{ href: '#blank-project-pane', id: 'blank-project-tab', data: { toggle: 'tab', experiment_track_label: 'blank_project' }, role: 'tab' } - %span.d-none.d-sm-block= s_('ProjectsNew|Blank project') - %span.d-block.d-sm-none= s_('ProjectsNew|Blank') - %li.nav-item{ role: 'presentation' } - %a.nav-link{ href: '#create-from-template-pane', id: 'create-from-template-tab', data: { toggle: 'tab', experiment_track_label: 'create_from_template' }, role: 'tab' } - %span.d-none.d-sm-block.qa-project-create-from-template-tab= s_('ProjectsNew|Create from template') - %span.d-block.d-sm-none= s_('ProjectsNew|Template') - %li.nav-item{ role: 'presentation' } - %a.nav-link{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab', experiment_track_label: 'import_project' }, role: 'tab' } - %span.d-none.d-sm-block= s_('ProjectsNew|Import project') - %span.d-block.d-sm-none= s_('ProjectsNew|Import') - = render_if_exists 'projects/new_ci_cd_only_project_tab', active_tab: active_tab - - .tab-content.gitlab-tab-content - .tab-pane.js-toggle-container{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' } - = form_for @project, html: { class: 'new_project' } do |f| - = render 'new_project_fields', f: f, project_name_id: "blank-project-name" - - #create-from-template-pane.tab-pane.js-toggle-container.px-0.pb-0{ class: active_when(active_tab == 'template'), role: 'tabpanel' } - .card.card-slim.m-4.p-4 + #blank-project-pane.tab-pane.active + = form_for @project, html: { class: 'new_project' } do |f| + = render 'new_project_fields', f: f, project_name_id: "blank-project-name" + + #create-from-template-pane.tab-pane + .gl-card.gl-my-5 + .gl-card-body + %div + - contributing_templates_url = 'https://gitlab.com/gitlab-org/project-templates/contributing' + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: contributing_templates_url } + = _('Learn how to %{link_start}contribute to the built-in templates%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + = form_for @project, html: { class: 'new_project' } do |f| + .project-template + .form-group %div - - contributing_templates_url = 'https://gitlab.com/gitlab-org/project-templates/contributing' - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: contributing_templates_url } - = _('Learn how to %{link_start}contribute to the built-in templates%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - = form_for @project, html: { class: 'new_project' } do |f| - .project-template - .form-group - %div - = render 'project_templates', f: f, project: @project - - .tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' } - - if import_sources_enabled? - = render 'import_project_pane', active_tab: active_tab - - else - .nothing-here-block - %h4= s_('ProjectsNew|No import options available') - %p= s_('ProjectsNew|Contact an administrator to enable options for importing your project.') + = render 'project_templates', f: f, project: @project - = render_if_exists 'projects/new_ci_cd_only_project_pane', active_tab: active_tab + #import-project-pane.tab-pane.js-toggle-container + - if import_sources_enabled? + = render 'import_project_pane' + - else + .nothing-here-block + %h4= s_('ProjectsNew|No import options available') + %p= s_('ProjectsNew|Contact an administrator to enable options for importing your project.') -.save-project-loader.d-none - .center - %h2 - .spinner.spinner-md.align-text-bottom - = s_('ProjectsNew|Creating project & repository.') - %p - = s_('ProjectsNew|Please wait a moment, this page will automatically refresh when ready.') + = render_if_exists 'projects/new_ci_cd_only_project_pane' diff --git a/app/views/projects/pipeline_schedules/_tabs.html.haml b/app/views/projects/pipeline_schedules/_tabs.html.haml index 61f6ad34052..f69041e1eb1 100644 --- a/app/views/projects/pipeline_schedules/_tabs.html.haml +++ b/app/views/projects/pipeline_schedules/_tabs.html.haml @@ -2,17 +2,17 @@ %li{ class: active_when(scope.nil?) }> = link_to schedule_path_proc.call(nil) do = s_("PipelineSchedules|All") - %span.badge.badge-pill.js-totalbuilds-count + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm.js-totalbuilds-count = number_with_delimiter(all_schedules.count(:id)) %li{ class: active_when(scope == 'active') }> = link_to schedule_path_proc.call('active') do = s_("PipelineSchedules|Active") - %span.badge.badge-pill + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm = number_with_delimiter(all_schedules.active.count(:id)) %li{ class: active_when(scope == 'inactive') }> = link_to schedule_path_proc.call('inactive') do = s_("PipelineSchedules|Inactive") - %span.badge.badge-pill + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm = number_with_delimiter(all_schedules.inactive.count(:id)) diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index f0b2349c493..e56a240c487 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -11,16 +11,16 @@ %li.js-builds-tab-link = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do = _('Jobs') - %span.badge.badge-pill.js-builds-counter= pipeline.total_size + %span.badge.badge-pill.gl-badge.badge-muted.sm.js-builds-counter= pipeline.total_size - if @pipeline.failed_builds.present? %li.js-failures-tab-link = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do = _('Failed Jobs') - %span.badge.badge-pill.js-failures-counter= @pipeline.failed_builds.count + %span.badge.badge-pill.gl-badge.badge-muted.sm.js-failures-counter= @pipeline.failed_builds.count %li.js-tests-tab-link = link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do = s_('TestReports|Tests') - %span.badge.badge-pill.js-test-report-badge-counter= @pipeline.test_report_summary.total[:count] + %span.badge.badge-pill.gl-badge.badge-muted.sm.js-test-report-badge-counter= @pipeline.test_report_summary.total[:count] = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project .tab-content @@ -83,5 +83,7 @@ #js-tab-tests.tab-pane #js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json), suite_endpoint: project_pipeline_test_path(@project, @pipeline, suite_name: 'suite', format: :json), - blob_path: project_blob_path(@project, @pipeline.sha) } } + blob_path: project_blob_path(@project, @pipeline.sha), + has_test_report: @pipeline.has_reports?(Ci::JobArtifact.test_reports).to_s, + empty_state_image_path: image_path('illustrations/empty-state/empty-test-cases-lg.svg') } } = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index 4b0487f4685..42bb8117766 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -1,12 +1,15 @@ - page_title _('Pipelines') - add_page_specific_style 'page_bundles/pipelines' - add_page_specific_style 'page_bundles/ci_status' +- artifacts_endpoint_placeholder = ':pipeline_artifacts_id' = render_if_exists "shared/shared_runners_minutes_limit_flash_message" -#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json), +#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json, code_quality_walkthrough: params[:code_quality_walkthrough]), project_id: @project.id, params: params.to_json, + "artifacts-endpoint" => downloadable_artifacts_project_pipeline_path(@project, artifacts_endpoint_placeholder, format: :json), + "artifacts-endpoint-placeholder" => artifacts_endpoint_placeholder, "pipeline-schedule-url" => pipeline_schedules_path(@project), "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'), "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'), @@ -17,4 +20,5 @@ "reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project), "has-gitlab-ci" => has_gitlab_ci?(@project).to_s, "add-ci-yml-path" => can?(current_user, :create_pipeline, @project) && @project.present(current_user: current_user).add_ci_yml_path, - "suggested-ci-templates" => experiment_suggested_ci_templates.to_json } } + "suggested-ci-templates" => experiment_suggested_ci_templates.to_json, + "code-quality-page-path" => @project.present(current_user: current_user).add_code_quality_ci_yml_path } } diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index 14de982e239..e92f14fcc63 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -1,55 +1,17 @@ - breadcrumb_title _('Pipelines') - page_title s_('Pipeline|Run pipeline') -- settings_link = link_to _('CI/CD settings'), project_settings_ci_cd_path(@project) %h3.page-title = s_('Pipeline|Run pipeline') %hr -- if Feature.enabled?(:new_pipeline_form, @project, default_enabled: :yaml) - #js-new-pipeline{ data: { project_id: @project.id, - pipelines_path: project_pipelines_path(@project), - config_variables_path: config_variables_namespace_project_pipelines_path(@project.namespace, @project), - default_branch: @project.default_branch, - ref_param: params[:ref] || @project.default_branch, - var_param: params[:var].to_json, - file_param: params[:file_var].to_json, - project_refs_endpoint: refs_project_path(@project, sort: 'updated_desc'), - settings_link: project_settings_ci_cd_path(@project), - max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } } - -- else - = form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "js-new-pipeline-form js-requires-input" } do |f| - = form_errors(@pipeline) - = pipeline_warnings(@pipeline) - .form-group.row - .col-sm-12 - = f.label :ref, s_('Pipeline|Run for'), class: 'col-form-label' - = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch - = dropdown_tag(params[:ref] || @project.default_branch, - options: { toggle_class: 'js-branch-select wide monospace', - filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: s_("Pipeline|Search branches"), - data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } }) - .form-text.text-muted - = s_("Pipeline|Existing branch name or tag") - - .col-sm-12.gl-mt-3.js-ci-variable-list-section - %label - = s_('Pipeline|Variables') - %ul.ci-variable-list - - if params[:var] - - params[:var].each do |variable| - = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable - - if params[:file_var] - - params[:file_var].each do |variable| - - variable.push("file") - = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable - = render 'ci/variables/variable_row', form_field: 'pipeline', only_key_value: true - .form-text.text-muted - = (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe - - .form-actions - = f.submit s_('Pipeline|Run pipeline'), class: 'btn gl-button btn-confirm gl-mr-3 js-variables-save-button' - = link_to _('Cancel'), project_pipelines_path(@project), class: 'btn gl-button btn-default' - - %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe +#js-new-pipeline{ data: { project_id: @project.id, + pipelines_path: project_pipelines_path(@project), + config_variables_path: config_variables_namespace_project_pipelines_path(@project.namespace, @project), + default_branch: @project.default_branch, + ref_param: params[:ref] || @project.default_branch, + var_param: params[:var].to_json, + file_param: params[:file_var].to_json, + project_refs_endpoint: refs_project_path(@project, sort: 'updated_desc'), + settings_link: project_settings_ci_cd_path(@project), + max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } } diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 98b1c5adcb5..93b0a525191 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -11,6 +11,10 @@ .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } #js-pipeline-header-vue.pipeline-header-container{ data: { full_path: @project.full_path, pipeline_iid: @pipeline.iid, pipeline_id: @pipeline.id, pipelines_path: project_pipelines_path(@project) } } + + - if @pipeline.failed? && @pipeline.user_not_verified? + #js-cc-validation-required-alert + - if @pipeline.commit.present? = render "projects/pipelines/info", commit: @pipeline.commit diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 22bf61b6873..0fa9fb7079b 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -4,7 +4,7 @@ .js-remove-member-modal .row.gl-mt-3 .col-lg-12 - - if can_invite_members_for_project?(@project) + - if can_invite_members_for_project?(@project) || can_invite_group_for_project?(@project) .row .col-md-12.col-lg-6.gl-display-flex .gl-flex-direction-column.gl-flex-wrap.align-items-baseline @@ -18,8 +18,15 @@ = html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe } .col-md-12.col-lg-6 .gl-display-flex.gl-flex-wrap.gl-justify-content-end - .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full', display_text: _('Invite a group') } } - .js-invite-members-trigger{ data: { variant: 'success', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } } + - if can_import_members? + = link_to _("Import a project"), + import_project_project_members_path(@project), + class: "btn btn-default btn-md gl-button gl-mt-3 gl-sm-w-auto gl-w-full", + title: _("Import members from another project") + - if @project.allowed_to_share_with_group? + .js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite a group') } } + - if !membership_locked? + .js-invite-members-trigger{ data: { variant: 'success', classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite members') } } = render 'projects/invite_members_modal', project: @project - else @@ -32,7 +39,7 @@ %p = html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe } - - if !can_invite_members_for_project?(@project) && can_manage_project_members?(@project) && project_can_be_shared? + - if Feature.disabled?(:invite_members_group_modal, @project.group) && can_manage_project_members?(@project) && project_can_be_shared? - if !membership_locked? && @project.allowed_to_share_with_group? %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } @@ -75,22 +82,21 @@ %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= @requesters.count .tab-content #tab-members.tab-pane{ class: ('active' unless groups_tab_active?) } - .js-project-members-list{ data: project_members_list_data_attributes(@project, @project_members) } + .js-project-members-list{ data: { members_data: project_members_list_data_json(@project, @project_members, { param_name: :page, params: { search_groups: nil } }) } } .loading - .spinner.spinner-md - = paginate @project_members, theme: "gitlab", params: { search_groups: nil } + .gl-spinner.gl-spinner-md - if show_groups?(@group_links) #tab-groups.tab-pane{ class: ('active' if groups_tab_active?) } - .js-project-group-links-list{ data: project_group_links_list_data_attributes(@project, @group_links) } + .js-project-group-links-list{ data: { members_data: project_group_links_list_data_json(@project, @group_links) } } .loading - .spinner.spinner-md + .gl-spinner.gl-spinner-md - if show_invited_members?(@project, @invited_members) #tab-invited-members.tab-pane - .js-project-invited-members-list{ data: project_members_list_data_attributes(@project, @invited_members) } + .js-project-invited-members-list{ data: { members_data: project_members_list_data_json(@project, @invited_members) } } .loading - .spinner.spinner-md + .gl-spinner.gl-spinner-md - if show_access_requests?(@project, @requesters) #tab-access-requests.tab-pane - .js-project-access-requests-list{ data: project_members_list_data_attributes(@project, @requesters) } + .js-project-access-requests-list{ data: { members_data: project_members_list_data_json(@project, @requesters) } } .loading - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/projects/project_templates/_template.html.haml b/app/views/projects/project_templates/_template.html.haml index e2bfd0881b5..827ff62f8c3 100644 --- a/app/views/projects/project_templates/_template.html.haml +++ b/app/views/projects/project_templates/_template.html.haml @@ -10,7 +10,7 @@ .controls.d-flex.align-items-center %a.btn.gl-button.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_event: "click_button", track_value: "" } } = _("Preview") - %label.btn.gl-button.btn-success.template-button.choose-template.gl-mb-0{ for: template.name } + %label.btn.gl-button.btn-confirm.template-button.choose-template.gl-mb-0{ for: template.name } %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_event: "click_button", track_value: "" } } %span{ data: { qa_selector: 'use_template_button' } } = _("Use template") diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index bbef5150a62..f56fd7f557d 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -12,7 +12,7 @@ "containers_error_image" => image_path('illustrations/docker-error-state.svg'), "repository_url" => escape_once(@project.container_registry_url), "registry_host_url_with_port" => escape_once(registry_config.host_port), - "expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'), + "expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index.md', anchor: 'cleanup-policy'), "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'), "project_path": @project.full_path, diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml index b37b530c33f..5d737bb3901 100644 --- a/app/views/projects/runners/_group_runners.html.haml +++ b/app/views/projects/runners/_group_runners.html.haml @@ -13,7 +13,7 @@ %br %br - if @project.group_runners_enabled? - = link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-warning-secondary', method: :post do + = link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-default', method: :post do = _('Disable group runners') - else = link_to toggle_group_runners_project_runners_path(@project), class: 'btn gl-button btn-confirm-secondary', method: :post do diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml index fccfca38013..3df4f3a0bd0 100644 --- a/app/views/projects/runners/_shared_runners.html.haml +++ b/app/views/projects/runners/_shared_runners.html.haml @@ -1,23 +1,6 @@ -- isVueifySharedRunnersToggleEnabled = Feature.enabled?(:vueify_shared_runners_toggle, @project) += render 'shared/runners/shared_runners_description' -= render layout: 'shared/runners/shared_runners_description' do - - if !isVueifySharedRunnersToggleEnabled - %br - %br - - if @project.group&.shared_runners_setting == 'disabled_and_unoverridable' - %h5.gl-text-red-500 - = _('Shared runners disabled on group level') - - else - - if @project.shared_runners_enabled? - = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn gl-button btn-warning-secondary', method: :post do - = _('Disable shared runners') - - else - = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn gl-button btn-confirm', method: :post do - = _('Enable shared runners') - for this project - -- if isVueifySharedRunnersToggleEnabled - #toggle-shared-runners-form{ data: toggle_shared_runners_settings_data(@project) } +#toggle-shared-runners-form{ data: toggle_shared_runners_settings_data(@project) } - if @shared_runners_count == 0 = _('This GitLab instance does not provide any shared runners yet. Instance administrators can register shared runners in the admin area.') diff --git a/app/views/projects/runners/edit.html.haml b/app/views/projects/runners/edit.html.haml index 77150715158..e87c52ff1a8 100644 --- a/app/views/projects/runners/edit.html.haml +++ b/app/views/projects/runners/edit.html.haml @@ -1,4 +1,7 @@ -- page_title _('Edit'), "#{@runner.description} ##{@runner.id}", _('Runners') +- breadcrumb_title _('Edit') +- page_title _('Edit'), "##{@runner.id} (#{@runner.short_sha})" +- add_to_breadcrumbs _('CI/CD Settings'), project_settings_ci_cd_path(@project) +- add_to_breadcrumbs "#{@runner.short_sha}", project_runner_path(@project, @runner) %h2.page-title = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id }) diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml new file mode 100644 index 00000000000..cb7984729c8 --- /dev/null +++ b/app/views/projects/runners/show.html.haml @@ -0,0 +1,3 @@ +- add_to_breadcrumbs _('CI/CD Settings'), project_settings_ci_cd_path(@project) + += render 'shared/runners/runner_details', runner: @runner diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml index 5e0f24cea21..be9bd3dfc01 100644 --- a/app/views/projects/settings/_archive.html.haml +++ b/app/views/projects/settings/_archive.html.haml @@ -14,7 +14,7 @@ method: :post, class: "gl-button btn btn-confirm" - else - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/index', anchor: 'archiving-a-project') } - %p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } + %p= _("Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end} %{link_start}Learn more.%{link_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, link_start: link_start, link_end: '</a>'.html_safe } = link_to _('Archive project'), archive_project_path(@project), data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' }, method: :post, class: "gl-button btn btn-warning" diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index 01f3e441eef..1bf252b6282 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -9,13 +9,13 @@ %h4.gl-mt-0 = page_title %p + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/settings/project_access_tokens') } - if current_user.can?(:create_resource_access_tokens, @project) - = _('You can generate an access token scoped to this project for each application to use the GitLab API.') - -# Commented out until https://gitlab.com/gitlab-org/gitlab/-/issues/219551 is fixed - -# %p - -# = _('You can also use project access tokens to authenticate against Git over HTTP.') + = _('Generate project access tokens scoped to this project for your applications that need access to the GitLab API.') + %p + = _('You can also use project access tokens with Git to authenticate over HTTP(S). %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - else - = _('Project access token creation is disabled in this group. You can still use and manage existing tokens.') + = _('Project access token creation is disabled in this group. You can still use and manage existing tokens. %{link_start}Learn more.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } %p - root_group = @project.group.root_ancestor - if current_user.can?(:admin_group, root_group) @@ -23,7 +23,6 @@ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_settings_link } = _('You can enable project access token creation in %{link_start}group settings%{link_end}.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - .col-lg-8 - if @new_project_access_token = render 'shared/access_tokens/created_container', diff --git a/app/views/projects/settings/operations/_configuration_banner.html.haml b/app/views/projects/settings/operations/_configuration_banner.html.haml index 8551aa5380e..6fa6b23b0da 100644 --- a/app/views/projects/settings/operations/_configuration_banner.html.haml +++ b/app/views/projects/settings/operations/_configuration_banner.html.haml @@ -14,7 +14,7 @@ .col-sm-10 %p.text-success.gl-mt-3 = s_('PrometheusService|GitLab manages Prometheus on your clusters.') - = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'btn' + = link_to s_('PrometheusService|Manage clusters'), project_clusters_path(project), class: 'gl-button btn btn-default' - else .col-sm-2 = image_tag 'illustrations/monitoring/loading.svg' diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml index 1e77f37ebb4..4ef9e1bd6fb 100644 --- a/app/views/projects/settings/operations/_error_tracking.html.haml +++ b/app/views/projects/settings/operations/_error_tracking.html.haml @@ -9,8 +9,8 @@ %button.gl-button.btn.btn-default.js-settings-toggle{ type: 'button' } = _('Expand') %p - = _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.') - = link_to _('More information'), help_page_path('operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer' + = _('Link Sentry to GitLab to discover and view the errors your application generates.') + = link_to _('Learn more.'), help_page_path('operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer' .settings-content .js-error-tracking-form{ data: { list_projects_endpoint: project_error_tracking_projects_path(@project, format: :json), operations_settings_endpoint: project_settings_operations_path(@project), diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index 73722a5a789..af183046e1e 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -1,6 +1,7 @@ - @content_class = 'limit-container-width' unless fluid_layout -- page_title _('Operations Settings') -- breadcrumb_title _('Operations Settings') +- title = Feature.enabled?(:sidebar_refactor, current_user, default_enabled: :yaml) ? _('Monitor Settings') : _('Operations Settings') +- page_title title +- breadcrumb_title title = render 'projects/settings/operations/alert_management' = render 'projects/settings/operations/incidents' diff --git a/app/views/projects/settings/packages_and_registries/show.html.haml b/app/views/projects/settings/packages_and_registries/show.html.haml new file mode 100644 index 00000000000..561ac7b347d --- /dev/null +++ b/app/views/projects/settings/packages_and_registries/show.html.haml @@ -0,0 +1,16 @@ +- breadcrumb_title _('Packages & Registries') +- page_title _('Packages & Registries') +- @content_class = 'limit-container-width' unless fluid_layout +- expanded = true + +%section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = _("Clean up image tags") + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _("Save space and find images in the Container Registry. Remove unneeded tags and keep only the ones you want.") + = link_to _('How does cleanup work?'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy'), target: '_blank', rel: 'noopener noreferrer' + .settings-content + = render 'projects/registry/settings/index' diff --git a/app/views/projects/sidebar/_issues_service_desk.html.haml b/app/views/projects/sidebar/_issues_service_desk.html.haml deleted file mode 100644 index 2730fe37f28..00000000000 --- a/app/views/projects/sidebar/_issues_service_desk.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -= nav_link(controller: :issues, action: :service_desk ) do - = link_to service_desk_project_issues_path(@project), title: 'Service Desk' do - = _('Service Desk') diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml index 726ab7d2372..a296394a2e0 100644 --- a/app/views/projects/snippets/show.html.haml +++ b/app/views/projects/snippets/show.html.haml @@ -6,6 +6,6 @@ #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} } .row-content-block.top-block.content-component-block - = render 'award_emoji/awards_block', awardable: @snippet, inline: true + = render 'award_emoji/awards_block', awardable: @snippet, inline: true, api_awards_path: project_snippets_award_api_path(@snippet) #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 1072d5bce06..83a3cac487f 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -8,7 +8,7 @@ = link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name' - if protected_tag?(@project, tag) - %span.badge.badge-success.gl-ml-2 + %span.badge.badge-success.gl-ml-2.gl-badge.sm.badge-pill = s_('TagsPage|protected') - if tag.message.present? @@ -42,5 +42,5 @@ - if can?(current_user, :admin_tag, @project) = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn gl-button btn-default btn-icon btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do - = sprite_icon("pencil") + = sprite_icon('pencil', css_class: 'gl-icon') = render 'projects/buttons/remove_tag', project: @project, tag: tag diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 229f13d0ff3..79205a51d71 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -9,28 +9,12 @@ = s_('TagsPage|Tags give the ability to mark specific points in history as being important') .nav-controls - - unless Gitlab::Ci::Features.gldropdown_tags_enabled? - = form_tag(filter_tags_path, method: :get) do - = search_field_tag :search, params[:search], { placeholder: s_('TagsPage|Filter by tag name'), id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false } - - .dropdown - %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown'} } - %span.light - = tags_sort_options_hash[@sort] - = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') - %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable - %li.dropdown-header - = s_('TagsPage|Sort by') - - tags_sort_options_hash.each do |value, title| - %li - = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value) - - else - #js-tags-sort-dropdown{ data: { filter_tags_path: filter_tags_path, sort_options: tags_sort_options_hash.to_json } } + #js-tags-sort-dropdown{ data: { filter_tags_path: filter_tags_path, sort_options: tags_sort_options_hash.to_json } } + = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn gl-button btn-default btn-icon has-tooltip gl-ml-auto' do + = sprite_icon('rss', css_class: 'gl-icon qa-rss-icon') - if can?(current_user, :admin_tag, @project) = link_to new_project_tag_path(@project), class: 'btn gl-button btn-confirm', data: { qa_selector: "new_tag_button" } do = s_('TagsPage|New tag') - = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-block has-tooltip' do - = sprite_icon('rss', css_class: 'qa-rss-icon') = render_if_exists 'projects/commits/mirror_status' diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 2ef1891089f..fe00772d1d6 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -51,7 +51,7 @@ = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do = render 'shared/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description, qa_selector: 'release_notes_field' = render 'shared/notes/hints' - .form-actions - = button_tag s_('TagsPage|Create tag'), class: 'gl-button btn btn-confirm', data: { qa_selector: "create_tag_button" } + .form-actions.gl-display-flex + = button_tag s_('TagsPage|Create tag'), class: 'gl-button btn btn-confirm gl-mr-3', data: { qa_selector: "create_tag_button" } = link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'gl-button btn btn-default btn-cancel' %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml index f181212b328..88594209c3b 100644 --- a/app/views/projects/tags/releases/edit.html.haml +++ b/app/views/projects/tags/releases/edit.html.haml @@ -14,6 +14,6 @@ = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…" = render 'shared/notes/hints' .error-alert - .gl-mt-3 - = f.submit 'Save changes', class: 'btn gl-button btn-confirm' + .gl-mt-5.gl-display-flex + = f.submit 'Save changes', class: 'btn gl-button btn-confirm gl-mr-3' = link_to "Cancel", project_tag_path(@project, @tag.name), class: "btn gl-button btn-default btn-cancel" diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml index 2def6c06458..081afacdaa6 100644 --- a/app/views/projects/triggers/_trigger.html.haml +++ b/app/views/projects/triggers/_trigger.html.haml @@ -30,8 +30,8 @@ %td.text-right.trigger-actions - revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?" - if can?(current_user, :admin_trigger, trigger) - = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "gl-button btn btn-default btn-sm" do + = link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "gl-button btn btn-default btn-icon" do = sprite_icon('pencil') - if can?(current_user, :manage_trigger, trigger) - = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button' }, method: :delete, title: "Revoke", class: "gl-button btn btn-default btn-sm btn-trigger-revoke" do + = link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation, testid: 'trigger_revoke_button' }, method: :delete, title: "Revoke", class: "gl-button btn btn-default btn-icon btn-trigger-revoke gl-ml-3" do = sprite_icon('remove') diff --git a/app/views/registrations/invites/new.html.haml b/app/views/registrations/invites/new.html.haml new file mode 100644 index 00000000000..6e6ff7aaeee --- /dev/null +++ b/app/views/registrations/invites/new.html.haml @@ -0,0 +1,18 @@ +- page_title _('Join your team') +- add_page_specific_style 'page_bundles/signup' +- content_for :page_specific_javascripts do + = render "layouts/google_tag_manager_head" += render "layouts/google_tag_manager_body" + +%h2.center.pt-6.pb-3.gl-mb-0 + = _('Join your team') +%p.gl-text-center= _('Create your own profile to collaborate with your teammates in issues, merge requests, and more.') + +.signup-page + = render 'devise/shared/signup_box', + url: users_sign_up_invites_path, + button_text: _('Continue'), + show_omniauth_providers: social_signin_enabled?, + omniauth_providers_placement: :top, + suggestion_path: nil + = render 'devise/shared/sign_in_link' diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml index bf5e35a1224..e85ce1ba6ac 100644 --- a/app/views/registrations/welcome/show.html.haml +++ b/app/views/registrations/welcome/show.html.haml @@ -25,6 +25,7 @@ = f.text_field :other_role, class: 'form-control' = render_if_exists "registrations/welcome/setup_for_company", f: f = render 'devise/shared/email_opted_in', f: f + = render_if_exists "registrations/welcome/jobs_to_be_done", f: f .row .form-group.col-sm-12.gl-mb-0 - if partial_exists? "registrations/welcome/button" diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index f094a6f5e3b..7f8a530deb8 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -13,23 +13,25 @@ = search_filter_link 'issues', _("Issues") - if project_search_tabs?(:merge_requests) = search_filter_link 'merge_requests', _("Merge requests") - - if project_search_tabs?(:milestones) - = search_filter_link 'milestones', _("Milestones") - - if project_search_tabs?(:notes) - = search_filter_link 'notes', _("Comments") - if project_search_tabs?(:wiki) = search_filter_link 'wiki_blobs', _("Wiki") - if project_search_tabs?(:commits) = search_filter_link 'commits', _("Commits") + - if project_search_tabs?(:notes) + = search_filter_link 'notes', _("Comments") + - if project_search_tabs?(:milestones) + = search_filter_link 'milestones', _("Milestones") = users - elsif @show_snippets = search_filter_link 'snippet_titles', _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil } - else = search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' } + = render_if_exists 'search/category_code' + = render_if_exists 'search/epics_filter_link' = search_filter_link 'issues', _("Issues") = search_filter_link 'merge_requests', _("Merge requests") - = search_filter_link 'milestones', _("Milestones") - = render_if_exists 'search/epics_filter_link' + = render_if_exists 'search/category_wiki' = render_if_exists 'search/category_elasticsearch' + = search_filter_link 'milestones', _("Milestones") = users diff --git a/app/views/search/results/_user.html.haml b/app/views/search/results/_user.html.haml index 8060a1577e4..9e70d9c9baa 100644 --- a/app/views/search/results/_user.html.haml +++ b/app/views/search/results/_user.html.haml @@ -1,9 +1,9 @@ %ul.content-list %li - .avatar-cell.d-none.d-sm-block - = user_avatar(user: user, user_name: user.name, css_class: 'd-none d-sm-inline avatar s40') + .avatar-cell + = user_avatar(user: user, size: 40, user_name: user.name) .user-info - = link_to user_path(user), class: 'd-none d-sm-inline' do + = link_to user_path(user) do .item-title = user.name = user_status(user) diff --git a/app/views/shared/_allow_request_access.html.haml b/app/views/shared/_allow_request_access.html.haml index ca82f2f3377..93868f13e58 100644 --- a/app/views/shared/_allow_request_access.html.haml +++ b/app/views/shared/_allow_request_access.html.haml @@ -1,6 +1,6 @@ - label_class = local_assigns.fetch(:bold_label, false) ? 'font-weight-bold' : '' -.form-check - = form.check_box :request_access_enabled, class: 'form-check-input', data: { qa_selector: 'request_access_checkbox' } - = form.label :request_access_enabled, class: 'form-check-label' do +.gl-form-checkbox.custom-control.custom-checkbox + = form.check_box :request_access_enabled, class: 'custom-control-input', data: { qa_selector: 'request_access_checkbox' } + = form.label :request_access_enabled, class: 'custom-control-label' do %span{ class: label_class }= _('Allow users to request access (if visibility is public or internal)') diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index 47ecc75af1f..904854c3fb7 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -7,11 +7,13 @@ .commit-message-container .max-width-marker = text_area_tag 'commit_message', - (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder]), - class: 'form-control gl-form-input js-commit-message', placeholder: local_assigns[:placeholder], - data: descriptions, - required: true, rows: (local_assigns[:rows] || 3), - id: "commit_message-#{nonce}" + (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder]), + class: 'form-control gl-form-input js-commit-message', + placeholder: local_assigns[:placeholder], + data: descriptions, + 'data-qa-selector': 'commit_message_field', + required: true, rows: (local_assigns[:rows] || 3), + id: "commit_message-#{nonce}" - if local_assigns[:hint] %p.hint = _('Try to keep the first line under 52 characters and the others under 72.') diff --git a/app/views/shared/_confirm_fork_modal.html.haml b/app/views/shared/_confirm_fork_modal.html.haml index 265396d3d8b..ed52aa01047 100644 --- a/app/views/shared/_confirm_fork_modal.html.haml +++ b/app/views/shared/_confirm_fork_modal.html.haml @@ -6,7 +6,7 @@ %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } %span{ "aria-hidden": true } × .modal-body.p-3 - %p= _("You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.") % { tag_start: '', tag_end: ''} + %p= _("You can’t %{tag_start}edit%{tag_end} files directly in this project. Fork this project and submit a merge request with your changes.") % { tag_start: '', tag_end: ''} .modal-footer = link_to _('Cancel'), '#', class: "btn gl-button btn-default", "data-dismiss" => "modal" = link_to _('Fork project'), fork_path, class: 'btn gl-button btn-confirm', data: { qa_selector: 'fork_project_button' }, method: :post diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index eea0c5f37de..7055dc8142a 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -6,7 +6,7 @@ .form-group.group-name-holder.col-sm-12 = f.label :name, class: 'label-bold' do = _("Group name") - = f.text_field :name, placeholder: _('My Awesome Group'), class: 'js-autofill-group-name form-control input-lg', + = f.text_field :name, placeholder: _('My Awesome Group'), class: 'js-autofill-group-name form-control input-lg', data: { qa_selector: 'group_name_field' }, required: true, title: _('Please fill in a descriptive name for your group.'), autofocus: true @@ -22,7 +22,7 @@ - if parent %strong= parent.full_path + '/' = f.hidden_field :parent_id - = f.text_field :path, placeholder: _('my-awesome-group'), class: 'form-control js-validate-group-path js-autofill-group-path', + = f.text_field :path, placeholder: _('my-awesome-group'), class: 'form-control js-validate-group-path js-autofill-group-path', data: { qa_selector: 'group_path_field' }, autofocus: local_assigns[:autofocus] || false, required: true, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, title: _('Please choose a group URL with no special characters.'), diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index 36d8aab6d53..cf9ee1a5231 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -6,21 +6,27 @@ = f.label :import_url, class: 'label-bold' do %span = _('Git repository URL') - = f.text_field :import_url, value: import_url.sanitized_url, - autocomplete: 'off', class: 'form-control', placeholder: 'https://gitlab.company.com/group/project.git', required: true + = f.text_field :import_url, + value: import_url.sanitized_url, + autocomplete: 'off', + class: 'form-control gl-form-input', + placeholder: 'https://gitlab.company.com/group/project.git', + required: true, + pattern: '(?:git|https?):\/\/.*/.*\.git$', + title: _('Please provide a valid URL ending with .git') .row .form-group.col-md-6 = f.label :import_url_user, class: 'label-bold' do %span = _('Username (optional)') - = f.text_field :import_url_user, value: import_url.user, class: 'form-control', required: false, autocomplete: 'new-password' + = f.text_field :import_url_user, value: import_url.user, class: 'form-control gl-form-input', required: false, autocomplete: 'new-password' .form-group.col-md-6 = f.label :import_url_password, class: 'label-bold' do %span = _('Password (optional)') - = f.password_field :import_url_password, class: 'form-control', required: false, autocomplete: 'new-password' + = f.password_field :import_url_password, class: 'form-control gl-form-input', required: false, autocomplete: 'new-password' .info-well.prepend-top-20 .well-segment diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml index 6c3e15cbace..01ab7bf9cd4 100644 --- a/app/views/shared/_issuable_meta_data.html.haml +++ b/app/views/shared/_issuable_meta_data.html.haml @@ -6,7 +6,7 @@ - issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count - if issuable_mr > 0 - %li.issuable-mr.gl-display-none.gl-sm-display-block.has-tooltip{ title: _('Related merge requests') } + %li.issuable-mr.gl-display-none.gl-sm-display-block.has-tooltip{ title: _('Related merge requests'), data: { testid: 'merge-requests' } } = sprite_icon('merge-request', css_class: "gl-vertical-align-middle") = issuable_mr diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml index eb12e9d463c..6eb736b0710 100644 --- a/app/views/shared/_issues.html.haml +++ b/app/views/shared/_issues.html.haml @@ -1,5 +1,7 @@ += render 'shared/alerts/positioning_disabled' + - if @issues.to_a.any? - %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position'), data: { group_full_path: @group&.full_path } } + %ul.content-list.issues-list.issuable-list{ class: issue_manual_ordering_class, data: { group_full_path: @group&.full_path } } = render partial: 'projects/issues/issue', collection: @issues = paginate @issues, theme: "gitlab" - else diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml index 2a2a1a911af..9c59d5ae1fa 100644 --- a/app/views/shared/access_tokens/_table.html.haml +++ b/app/views/shared/access_tokens/_table.html.haml @@ -18,7 +18,7 @@ %th= s_('AccessTokens|Created') %th = _('Last Used') - = link_to sprite_icon('question-o'), help_page_path('user/profile/personal_access_tokens.md', anchor: 'token-activity'), target: '_blank' + = link_to sprite_icon('question-o'), help_page_path('user/profile/personal_access_tokens.md', anchor: 'view-the-last-time-a-token-was-used'), target: '_blank' %th= _('Expires') %th= _('Scopes') %th diff --git a/app/views/shared/alerts/_positioning_disabled.html.haml b/app/views/shared/alerts/_positioning_disabled.html.haml new file mode 100644 index 00000000000..91c1d3463d8 --- /dev/null +++ b/app/views/shared/alerts/_positioning_disabled.html.haml @@ -0,0 +1,2 @@ +- if issue_repositioning_disabled? + = render 'shared/alert_info', body: _('Issues manual ordering is temporarily disabled for technical reasons.') diff --git a/app/views/shared/blob/_markdown_buttons.html.haml b/app/views/shared/blob/_markdown_buttons.html.haml index 73f3d2a8fcd..033ed69da41 100644 --- a/app/views/shared/blob/_markdown_buttons.html.haml +++ b/app/views/shared/blob/_markdown_buttons.html.haml @@ -19,6 +19,9 @@ = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "- ", "md-prepend" => true }, title: _("Add a bullet list") }) = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") }) = markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "- [ ] ", "md-prepend" => true }, title: _("Add a task list") }) + = markdown_toolbar_button({ icon: "details-block", + data: { "md-tag" => "<details><summary>Click to expand</summary>\n{text}\n</details>", "md-prepend" => true, "md-select" => "Click to expand" }, + title: _("Add a collapsible section") }) = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: _("Add a table") }) - if show_fullscreen_button %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: _("Go full screen"), data: { container: "body" } } diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index bf70149812a..c1a50cfe718 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -7,6 +7,8 @@ - breadcrumb_title _("Epic Boards") - else - breadcrumb_title _("Issue Boards") + = render 'shared/alerts/positioning_disabled' + - page_title("#{board.name}", _("Boards")) - add_page_specific_style 'page_bundles/boards' diff --git a/app/views/shared/boards/components/sidebar/_assignee.html.haml b/app/views/shared/boards/components/sidebar/_assignee.html.haml index c36f2c7c969..79817025565 100644 --- a/app/views/shared/boards/components/sidebar/_assignee.html.haml +++ b/app/views/shared/boards/components/sidebar/_assignee.html.haml @@ -1,9 +1,10 @@ - dropdown_options = assignees_dropdown_options('issue') +- relative_url = Gitlab.config.gitlab.relative_url_root || '/' .block.assignee{ ref: "assigneeBlock" } %template{ "v-if" => "issue.assignees" } %sidebar-assignees-widget{ ":iid" => "String(issue.iid)", - ":full-path" => "issue.path.split('/-/')[0].substring(1)", + ":full-path" => "issue.path.split('/-/')[0].substring(1).replace(`#{relative_url}`, '')", ":initial-assignees" => "issue.assignees", - ":multiple-assignees" => "!Boolean(#{dropdown_options[:data][:"max-select"]})", + ":allow-multiple-assignees" => "!Boolean(#{dropdown_options[:data][:"max-select"]})", "@assignees-updated" => "setAssignees" } diff --git a/app/views/shared/builds/_tabs.html.haml b/app/views/shared/builds/_tabs.html.haml index 5c74e71b644..4973309edf5 100644 --- a/app/views/shared/builds/_tabs.html.haml +++ b/app/views/shared/builds/_tabs.html.haml @@ -2,23 +2,23 @@ %li{ class: active_when(scope.nil?) }> = link_to build_path_proc.call(nil) do All - %span.badge.badge-pill.js-totalbuilds-count + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm.js-totalbuilds-count = limited_counter_with_delimiter(all_builds) %li{ class: active_when(scope == 'pending') }> = link_to build_path_proc.call('pending') do Pending - %span.badge.badge-pill + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm = limited_counter_with_delimiter(all_builds.pending) %li{ class: active_when(scope == 'running') }> = link_to build_path_proc.call('running') do Running - %span.badge.badge-pill + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm = limited_counter_with_delimiter(all_builds.running) %li{ class: active_when(scope == 'finished') }> = link_to build_path_proc.call('finished') do Finished - %span.badge.badge-pill + %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm = limited_counter_with_delimiter(all_builds.finished) diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml index 37a56057268..452e54f9cd4 100644 --- a/app/views/shared/deploy_keys/_form.html.haml +++ b/app/views/shared/deploy_keys/_form.html.haml @@ -6,7 +6,7 @@ .form-group = form.label :title, class: 'col-form-label col-sm-2' - .col-sm-10= form.text_field :title, class: 'form-control gl-form-input', readonly: ('readonly' unless can?(current_user, :update_deploy_key, deploy_key)) + .col-sm-10= form.text_field :title, class: 'form-control gl-form-input', data: { qa_selector: 'deploy_key_title_field' }, readonly: ('readonly' unless can?(current_user, :update_deploy_key, deploy_key)) .form-group - if deploy_key.new_record? @@ -16,7 +16,7 @@ - link_start = "<a href='#{help_page_path('ssh/README')}' target='_blank' rel='noreferrer noopener'>".html_safe - link_end = '</a>' = _('Paste a public key here. %{link_start}How do I generate it?%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe } - = form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5 + = form.text_area :key, class: 'form-control gl-form-input thin_area', rows: 5, data: { qa_selector: 'deploy_key_field' } - else = form.label :fingerprint, class: 'col-form-label col-sm-2' .col-sm-10 diff --git a/app/views/shared/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml index be6fe94e497..388fe75e833 100644 --- a/app/views/shared/deploy_keys/_index.html.haml +++ b/app/views/shared/deploy_keys/_index.html.haml @@ -1,5 +1,5 @@ - expanded = expanded_by_default? -%section.qa-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_keys_settings_content' } } +%section.rspec-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_keys_settings_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Deploy keys') %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml index 25357ccdc65..0c671b4a1c0 100644 --- a/app/views/shared/deploy_keys/_project_group_form.html.haml +++ b/app/views/shared/deploy_keys/_project_group_form.html.haml @@ -2,10 +2,10 @@ = form_errors(@deploy_keys.new_key) .form-group.row = f.label :title, class: "label-bold" - = f.text_field :title, class: 'form-control gl-form-input', required: true + = f.text_field :title, class: 'form-control gl-form-input', required: true, data: { qa_selector: 'deploy_key_title_field' } .form-group.row = f.label :key, class: "label-bold" - = f.text_area :key, class: 'form-control gl-form-input', rows: 5, required: true + = f.text_area :key, class: 'form-control gl-form-input', rows: 5, required: true, data: { qa_selector: 'deploy_key_field' } .form-group.row %p.light.gl-mb-0 = _('Paste a public key here.') @@ -21,4 +21,4 @@ = _('Allow this key to push to this repository') .form-group.row - = f.submit _("Add key"), class: "btn gl-button btn-confirm" + = f.submit _("Add key"), class: "btn gl-button btn-confirm", data: { qa_selector: "add_deploy_key_button"} diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index 4d0858165a2..976776ccc62 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -6,28 +6,28 @@ .form-group = f.label :name, class: 'label-bold' - = f.text_field :name, class: 'form-control gl-form-input qa-deploy-token-name', required: true + = f.text_field :name, class: 'form-control gl-form-input', data: { qa_selector: 'deploy_token_name_field' }, required: true .form-group = f.label :expires_at, _('Expires at (optional)'), class: 'label-bold' - = f.text_field :expires_at, class: 'datepicker form-control qa-deploy-token-expires-at', value: f.object.expires_at + = f.text_field :expires_at, class: 'datepicker form-control', data: { qa_selector: 'deploy_token_expires_at_field' }, value: f.object.expires_at .text-secondary= s_('DeployTokens|Unless you enter a date, the token does not expire.') .form-group = f.label :username, _('Username (optional)'), class: 'label-bold' - = f.text_field :username, class: 'form-control qa-deploy-token-username' + = f.text_field :username, class: 'form-control' .text-secondary= s_('DeployTokens|Unless you specify a username, it is set to "gitlab+deploy-token-{n}".') .form-group = f.label :scopes, _('Scopes [Select 1 or more]'), class: 'label-bold' %fieldset.form-group.form-check - = f.check_box :read_repository, class: 'form-check-input qa-deploy-token-read-repository' + = f.check_box :read_repository, class: 'form-check-input', data: { qa_selector: 'deploy_token_read_repository_checkbox' } = f.label :read_repository, 'read_repository', class: 'label-bold form-check-label' .text-secondary= s_('DeployTokens|Allows read-only access to the repository.') - if container_registry_enabled?(group_or_project) %fieldset.form-group.form-check - = f.check_box :read_registry, class: 'form-check-input qa-deploy-token-read-registry' + = f.check_box :read_registry, class: 'form-check-input', data: { qa_selector: 'deploy_token_read_registry_checkbox' } = f.label :read_registry, 'read_registry', class: 'label-bold form-check-label' .text-secondary= s_('DeployTokens|Allows read-only access to registry images.') @@ -48,4 +48,4 @@ .text-secondary= s_('DeployTokens|Allows write access to the package registry.') .gl-mt-3 - = f.submit s_('DeployTokens|Create deploy token'), class: 'btn gl-button btn-confirm qa-create-deploy-token' + = f.submit s_('DeployTokens|Create deploy token'), class: 'btn gl-button btn-confirm', data: { qa_selector: 'create_deploy_token_button' } diff --git a/app/views/shared/deploy_tokens/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml index 9d1a24d4c24..3e8368b7b78 100644 --- a/app/views/shared/deploy_tokens/_index.html.haml +++ b/app/views/shared/deploy_tokens/_index.html.haml @@ -1,9 +1,9 @@ - expanded = expand_deploy_tokens_section?(@new_deploy_token) -%section.qa-deploy-tokens-settings.settings.no-animate#js-deploy-tokens{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_tokens_settings_content' } } +%section.settings.no-animate#js-deploy-tokens{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_tokens_settings_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= s_('DeployTokens|Deploy tokens') - %button.btn.gl-button.btn-default.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle = expanded ? 'Collapse' : 'Expand' %p = description diff --git a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml index 41e50138220..9c82d5685f8 100644 --- a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml +++ b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml @@ -1,11 +1,11 @@ -.qa-created-deploy-token-section.created-deploy-token-container.info-well +.created-deploy-token-container.info-well{ data: { qa_selector: 'created_deploy_token_container' } } .well-segment %h5.gl-mt-0 = s_('DeployTokens|Your new Deploy Token username') .form-group .input-group - = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus qa-deploy-token-user' + = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { qa_selector: 'deploy_token_user_field' } .input-group-append = clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username'), placement: 'left') %span.deploy-token-help-block.gl-mt-2.text-success @@ -15,7 +15,7 @@ .form-group .input-group - = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus qa-deploy-token' + = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { qa_selector: 'deploy_token_field' } .input-group-append = clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token'), placement: 'left') %span.deploy-token-help-block.gl-mt-2.text-danger diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 1e340f033a1..3e89969f46e 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -20,9 +20,11 @@ = hidden_field_tag :search, params[:search] - if @can_bulk_update .check-all-holder.d-none.d-sm-block.hidden - = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left" + - checkbox_id = 'check-all-issues' + %label.gl-sr-only{ for: checkbox_id }= _('Select all') + = check_box_tag checkbox_id, nil, false, class: "check-all-issues left" - if Feature.enabled?(:boards_filtered_search, @group) && is_epic_board - #js-board-filtered-search + #js-board-filtered-search{ data: { full_path: @group&.full_path } } - else .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row .filtered-search-box diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 47e7ff0e4bc..86369b32e98 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -1,7 +1,10 @@ - issuable_type = issuable_sidebar[:type] - dropdown_options = assignees_dropdown_options(issuable_type) -#js-vue-sidebar-assignees{ data: { field: issuable_type, signed_in: signed_in, max_assignees: dropdown_options[:data][:"max-select"], directly_invite_members: directly_invite_members?, indirectly_invite_members: indirectly_invite_members? } } +#js-vue-sidebar-assignees{ data: { field: issuable_type, + signed_in: signed_in, + max_assignees: dropdown_options[:data][:"max-select"], + directly_invite_members: directly_invite_members? } } .title.hide-collapsed = _('Assignee') = loading_icon(css_class: 'gl-vertical-align-text-bottom') @@ -39,12 +42,12 @@ - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select'] - options[:data].merge!(data) - - if directly_invite_members? || indirectly_invite_members? + - if directly_invite_members? - options[:dropdown_class] += ' dropdown-extended-height' - options[:footer_content] = true - options[:wrapper_class] = 'js-sidebar-assignee-dropdown' - options[:toggle_class] += ' js-invite-members-track' - - data['track-event'] = show_invite_members_track_event + - data['track-event'] = 'show_invite_members' - options[:data].merge!(data) - invite_text = _('Invite Members') - track_label = 'edit_assignee' @@ -52,15 +55,9 @@ = dropdown_tag(title, options: options) do %ul.dropdown-footer-list %li - - if directly_invite_members? - .js-invite-members-trigger{ data: { trigger_element: 'anchor', - display_text: invite_text, - event: 'click_invite_members', - label: track_label } } - - else - .js-invite-member-trigger{ data: { display_text: invite_text, event: 'click_invite_members_version_b', label: track_label } } + .js-invite-members-trigger{ data: { trigger_element: 'anchor', + display_text: invite_text, + event: 'click_invite_members', + label: track_label } } - else = dropdown_tag(title, options: options) - -- if indirectly_invite_members? - .js-invite-member-modal{ data: { members_path: project_project_members_path(@project, sort: :access_level_desc) } } diff --git a/app/views/shared/issuable/_status_box.html.haml b/app/views/shared/issuable/_status_box.html.haml new file mode 100644 index 00000000000..c0e972684d2 --- /dev/null +++ b/app/views/shared/issuable/_status_box.html.haml @@ -0,0 +1,6 @@ +- state_human_name, state_icon_name = state_name_with_icon(issuable) + +.issuable-status-box.status-box.js-mr-status-box{ class: status_box_class(issuable), data: { project_path: issuable.project.path_with_namespace, iid: issuable.iid, state: issuable.state } } + = sprite_icon(state_icon_name, css_class: 'gl-display-block gl-sm-display-none!') + %span.gl-display-none.gl-sm-display-block + = state_human_name diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 94d0c395fa6..561ca0afd60 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -5,11 +5,8 @@ - div_class = no_issuable_templates ? 'col-sm-10' : 'col-sm-7 col-lg-8' - toggle_wip_link_start = '<a href="" class="js-toggle-wip">' - toggle_wip_link_end = '</a>' -- draft_snippet = '<code>Draft:</code>'.html_safe -- wip_snippet = '<code>WIP:</code>'.html_safe -- draft_or_wip_snippet = '<code>Draft/WIP</code>'.html_safe -- add_wip_text = (_('%{link_start}Start the title with %{draft_snippet} or %{wip_snippet}%{link_end} to prevent a merge request that is a work in progress from being merged before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: draft_snippet, wip_snippet: wip_snippet } ).html_safe -- remove_wip_text = (_('%{link_start}Remove the %{draft_or_wip_snippet} prefix%{link_end} from the title to allow this merge request to be merged when it\'s ready.' ) % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_or_wip_snippet: draft_or_wip_snippet } ).html_safe +- add_wip_text = (_('%{link_start}Start the title with %{draft_snippet}%{link_end} to prevent a merge request that is a work in progress from being merged before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft:</code>'.html_safe } ).html_safe +- remove_wip_text = (_('%{link_start}Remove the %{draft_snippet} prefix%{link_end} from the title to allow this merge request to be merged when it\'s ready.' ) % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: '<code>Draft</code>'.html_safe } ).html_safe %div{ class: div_class } = form.text_field :title, required: true, maxlength: 255, autofocus: true, diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml index 36a68dfdaa7..a25e35cdcd4 100644 --- a/app/views/shared/issue_type/_details_header.html.haml +++ b/app/views/shared/issue_type/_details_header.html.haml @@ -1,12 +1,17 @@ +- link = issue_closed_link(@issue, current_user, css_class: 'text-white text-underline') + .detail-page-header .detail-page-header-body .issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(issuable, status_box: :closed) } = sprite_icon('issue-close', css_class: 'gl-display-block gl-sm-display-none!') - .gl-display-none.gl-sm-display-block! + .gl-display-none.gl-sm-display-block = issue_closed_text(issuable, current_user) + - if link + %span.text-white.gl-pl-2.gl-sm-display-none + = "(#{link})" .issuable-status-box.status-box.status-box-open{ class: issue_status_visibility(issuable, status_box: :open) } = sprite_icon('issue-open-m', css_class: 'gl-display-block gl-sm-display-none!') - %span.gl-display-none.gl-sm-display-block! + %span.gl-display-none.gl-sm-display-block = _('Open') .issuable-meta diff --git a/app/views/shared/members/_invite_member.html.haml b/app/views/shared/members/_invite_member.html.haml index ad0ba6dcedf..49111c821b1 100644 --- a/app/views/shared/members/_invite_member.html.haml +++ b/app/views/shared/members/_invite_member.html.haml @@ -23,6 +23,6 @@ .clearable-input = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date' = sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input gl-text-gray-200') - = submit_tag _("Invite"), class: "gl-button btn btn-confirm gl-mr-3", data: { qa_selector: 'invite_member_button' } + = submit_tag _("Invite"), class: "gl-button btn btn-confirm gl-mr-2", data: { qa_selector: 'invite_member_button' } - if can_import_members = link_to _("Import"), import_path, class: "gl-button btn btn-default", title: _("Import members from another project") diff --git a/app/views/shared/milestones/_delete_button.html.haml b/app/views/shared/milestones/_delete_button.html.haml index 09c783a0b24..6d4ff255f06 100644 --- a/app/views/shared/milestones/_delete_button.html.haml +++ b/app/views/shared/milestones/_delete_button.html.haml @@ -7,6 +7,6 @@ milestone_merge_request_count: @milestone.merge_requests.count }, disabled: true } = _('Delete') - .spinner.js-loading-icon.hidden + .gl-spinner.js-loading-icon.hidden #js-delete-milestone-modal diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml index e995584309a..e0664c1feba 100644 --- a/app/views/shared/milestones/_form_dates.html.haml +++ b/app/views/shared/milestones/_form_dates.html.haml @@ -3,11 +3,11 @@ .col-form-label.col-sm-2 = f.label :start_date, _('Start Date') .col-sm-10 - = f.text_field :start_date, class: "datepicker form-control", data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off' + = f.text_field :start_date, class: "datepicker form-control gl-form-input", data: { qa_selector: "start_date_field" }, placeholder: _('Select start date'), autocomplete: 'off' %a.inline.float-right.gl-mt-2.js-clear-start-date{ href: "#" }= _('Clear start date') .form-group.row .col-form-label.col-sm-2 = f.label :due_date, _('Due Date') .col-sm-10 - = f.text_field :due_date, class: "datepicker form-control", data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off' + = f.text_field :due_date, class: "datepicker form-control gl-form-input", data: { qa_selector: "due_date_field" }, placeholder: _('Select due date'), autocomplete: 'off' %a.inline.float-right.gl-mt-2.js-clear-due-date{ href: "#" }= _('Clear due date') diff --git a/app/views/shared/milestones/_search_form.html.haml b/app/views/shared/milestones/_search_form.html.haml index 403a0224a85..1c51f1ad09d 100644 --- a/app/views/shared/milestones/_search_form.html.haml +++ b/app/views/shared/milestones/_search_form.html.haml @@ -1,7 +1,7 @@ = form_tag request.path, method: :get do |f| = search_field_tag :search_title, params[:search_title], placeholder: _('Filter by milestone name'), - class: 'form-control input-short', + class: 'form-control gl-form-input input-short', spellcheck: false = hidden_field_tag :state, params[:state] = hidden_field_tag :sort, params[:sort] diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 0e54f1a7672..0088cd35781 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -79,7 +79,7 @@ %span= milestone.issues_visible_to_user(current_user).count .title.hide-collapsed = s_('MilestoneSidebar|Issues') - %span.badge.badge-pill= milestone.issues_visible_to_user(current_user).count + %span.badge.badge-muted.badge-pill.gl-badge.sm= milestone.issues_visible_to_user(current_user).count - if show_new_issue_link?(project) = link_to new_project_issue_path(project, issue: { milestone_id: milestone.id }), class: "float-right", title: s_('MilestoneSidebar|New Issue') do = s_('MilestoneSidebar|New issue') @@ -110,7 +110,7 @@ %span= milestone.merge_requests.count .title.hide-collapsed = s_('MilestoneSidebar|Merge requests') - %span.badge.badge-pill= milestone.merge_requests.count + %span.badge.badge-muted.badge-pill.gl-badge.sm= milestone.merge_requests.count .value.hide-collapsed.bold - if !project || can?(current_user, :read_merge_request, project) %span.milestone-stat diff --git a/app/views/shared/milestones/_tab_loading.html.haml b/app/views/shared/milestones/_tab_loading.html.haml index fe1184114e9..b19e994ef80 100644 --- a/app/views/shared/milestones/_tab_loading.html.haml +++ b/app/views/shared/milestones/_tab_loading.html.haml @@ -1,2 +1,2 @@ .text-center.gl-mt-3 - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml b/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml index 1e9aa4ec5ff..ab4d8816ec9 100644 --- a/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml +++ b/app/views/shared/namespaces/cascading_settings/_enforcement_checkbox.html.haml @@ -1,14 +1,17 @@ - attribute = local_assigns.fetch(:attribute, nil) -- group = local_assigns.fetch(:group, nil) - form = local_assigns.fetch(:form, nil) +- setting_locked = local_assigns.fetch(:setting_locked, false) +- help_text = local_assigns.fetch(:help_text, s_('CascadingSettings|Subgroups cannot change this setting.')) - return unless attribute && group && form && cascading_namespace_settings_enabled? -- return if group.namespace_settings.public_send("#{attribute}_locked?") +- return if setting_locked - lock_attribute = "lock_#{attribute}" .gl-form-checkbox.custom-control.custom-checkbox = form.check_box lock_attribute, checked: group.namespace_settings.public_send(lock_attribute), class: 'custom-control-input', data: { testid: 'enforce-for-all-subgroups-checkbox' } = form.label lock_attribute, class: 'custom-control-label' do - %span= s_('CascadingSettings|Enforce for all subgroups') - %p.help-text= s_('CascadingSettings|Subgroups cannot change this setting.') + %span + = yield.presence || s_('CascadingSettings|Enforce for all subgroups') + %p.help-text + = help_text diff --git a/app/views/shared/namespaces/cascading_settings/_lock_icon.html.haml b/app/views/shared/namespaces/cascading_settings/_lock_icon.html.haml new file mode 100644 index 00000000000..4e3b6b2afc4 --- /dev/null +++ b/app/views/shared/namespaces/cascading_settings/_lock_icon.html.haml @@ -0,0 +1,4 @@ +%button.position-absolute.gl-top-3.gl-right-0.gl-translate-y-n50.gl-cursor-default.btn.btn-default.btn-sm.gl-button.btn-default-tertiary.js-cascading-settings-lock-popover-target{ class: 'gl-p-1! gl-text-gray-600! gl-bg-transparent!', + type: 'button', + data: cascading_namespace_settings_popover_data(attribute, group, settings_path_helper) } + = sprite_icon('lock', size: 16) diff --git a/app/views/shared/namespaces/cascading_settings/_setting_label.html.haml b/app/views/shared/namespaces/cascading_settings/_setting_label.html.haml deleted file mode 100644 index 6596ce2bc73..00000000000 --- a/app/views/shared/namespaces/cascading_settings/_setting_label.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -- attribute = local_assigns.fetch(:attribute, nil) -- group = local_assigns.fetch(:group, nil) -- form = local_assigns.fetch(:form, nil) -- settings_path_helper = local_assigns.fetch(:settings_path_helper, nil) -- help_text = local_assigns.fetch(:help_text, nil) - -- return unless attribute && group && form && settings_path_helper - -- setting_locked = group.namespace_settings.public_send("#{attribute}_locked?") - -= form.label attribute, class: 'custom-control-label', aria: { disabled: setting_locked } do - %span.position-relative.gl-pr-6.gl-display-inline-flex - = yield - - if setting_locked - %button.position-absolute.gl-top-3.gl-right-0.gl-translate-y-n50.gl-cursor-default.btn.btn-default.btn-sm.gl-button.btn-default-tertiary.js-cascading-settings-lock-popover-target{ class: 'gl-p-1! gl-text-gray-600! gl-bg-transparent!', - type: 'button', - data: cascading_namespace_settings_popover_data(attribute, group, settings_path_helper) } - = sprite_icon('lock', size: 16) - - if help_text - %p.help-text - = help_text diff --git a/app/views/shared/namespaces/cascading_settings/_setting_label_checkbox.html.haml b/app/views/shared/namespaces/cascading_settings/_setting_label_checkbox.html.haml new file mode 100644 index 00000000000..d27b3641637 --- /dev/null +++ b/app/views/shared/namespaces/cascading_settings/_setting_label_checkbox.html.haml @@ -0,0 +1,16 @@ +- attribute = local_assigns.fetch(:attribute, nil) +- settings_path_helper = local_assigns.fetch(:settings_path_helper, nil) +- form = local_assigns.fetch(:form, nil) +- setting_locked = local_assigns.fetch(:setting_locked, false) +- help_text = local_assigns.fetch(:help_text, nil) + +- return unless attribute && form && settings_path_helper + += form.label attribute, class: 'custom-control-label', aria: { disabled: setting_locked } do + = render 'shared/namespaces/cascading_settings/setting_label_container' do + = yield + - if setting_locked + = render 'shared/namespaces/cascading_settings/lock_icon', local_assigns + - if help_text + %p.help-text + = help_text diff --git a/app/views/shared/namespaces/cascading_settings/_setting_label_container.html.haml b/app/views/shared/namespaces/cascading_settings/_setting_label_container.html.haml new file mode 100644 index 00000000000..7323295f1fe --- /dev/null +++ b/app/views/shared/namespaces/cascading_settings/_setting_label_container.html.haml @@ -0,0 +1,2 @@ +%span.position-relative.gl-pr-6.gl-display-inline-flex + = yield diff --git a/app/views/shared/namespaces/cascading_settings/_setting_label_fieldset.html.haml b/app/views/shared/namespaces/cascading_settings/_setting_label_fieldset.html.haml new file mode 100644 index 00000000000..4a2ec9f30fd --- /dev/null +++ b/app/views/shared/namespaces/cascading_settings/_setting_label_fieldset.html.haml @@ -0,0 +1,15 @@ +- attribute = local_assigns.fetch(:attribute, nil) +- settings_path_helper = local_assigns.fetch(:settings_path_helper, nil) +- setting_locked = local_assigns.fetch(:setting_locked, false) +- help_text = local_assigns.fetch(:help_text, nil) + +- return unless attribute && settings_path_helper + +%legend.h5.gl-border-none.gl-m-0 + = render 'shared/namespaces/cascading_settings/setting_label_container' do + = yield + - if setting_locked + = render 'shared/namespaces/cascading_settings/lock_icon', local_assigns +- if help_text + %p.gl-text-gray-500 + = help_text diff --git a/app/views/shared/nav/_scope_menu.html.haml b/app/views/shared/nav/_scope_menu.html.haml index 270587f48a8..2f10914ef3d 100644 --- a/app/views/shared/nav/_scope_menu.html.haml +++ b/app/views/shared/nav/_scope_menu.html.haml @@ -1,6 +1,6 @@ .context-header = link_to scope_menu.link, **scope_menu.container_html_options do - .avatar-container.rect-avatar.s40.project-avatar + %span.avatar-container.rect-avatar.s40.project-avatar = source_icon(scope_menu.container, alt: scope_menu.title, class: 'avatar s40 avatar-tile', width: 40, height: 40) - .sidebar-context-title + %span.sidebar-context-title = scope_menu.title diff --git a/app/views/shared/nav/_sidebar.html.haml b/app/views/shared/nav/_sidebar.html.haml index 1c06fc9eebf..552dcbfd6fd 100644 --- a/app/views/shared/nav/_sidebar.html.haml +++ b/app/views/shared/nav/_sidebar.html.haml @@ -11,4 +11,5 @@ - if sidebar.render_raw_menus_partial = render sidebar.render_raw_menus_partial + = render partial: 'shared/nav/sidebar_hidden_menu_item', collection: sidebar.hidden_menu&.renderable_items = render 'shared/sidebar_toggle_button' diff --git a/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml b/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml new file mode 100644 index 00000000000..953f7a8ae60 --- /dev/null +++ b/app/views/shared/nav/_sidebar_hidden_menu_item.html.haml @@ -0,0 +1,3 @@ +%li.hidden + = link_to sidebar_hidden_menu_item.link, **sidebar_hidden_menu_item.container_html_options do + = sidebar_hidden_menu_item.title diff --git a/app/views/shared/nav/_sidebar_menu.html.haml b/app/views/shared/nav/_sidebar_menu.html.haml index c6e86a90ba7..67c775d1a85 100644 --- a/app/views/shared/nav/_sidebar_menu.html.haml +++ b/app/views/shared/nav/_sidebar_menu.html.haml @@ -1,7 +1,7 @@ = nav_link(**sidebar_menu.all_active_routes, html_options: sidebar_menu.nav_link_html_options) do = link_to sidebar_menu.link, **sidebar_menu.container_html_options, data: { qa_selector: 'sidebar_menu_link', qa_menu_item: sidebar_menu.title } do - if sidebar_menu.icon_or_image? - .nav-icon-container + %span.nav-icon-container - if sidebar_menu.image_path = image_tag(sidebar_menu.image_path, **sidebar_menu.image_html_options) - elsif sidebar_menu.sprite_icon @@ -13,15 +13,15 @@ %span.badge.badge-pill.count{ **sidebar_menu.pill_html_options } = number_with_delimiter(sidebar_menu.pill_count) - %ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_items?) } + %ul.sidebar-sub-level-items{ class: ('is-fly-out-only' unless sidebar_menu.has_renderable_items?) } = nav_link(**sidebar_menu.all_active_routes, html_options: { class: 'fly-out-top-item' } ) do - = link_to sidebar_menu.link, aria: { label: sidebar_menu.title } do + = link_to sidebar_menu.link, **sidebar_menu.collapsed_container_html_options do %strong.fly-out-top-item-name = sidebar_menu.title - if sidebar_menu.has_pill? %span.badge.badge-pill.count.fly-out-badge{ **sidebar_menu.pill_html_options } = number_with_delimiter(sidebar_menu.pill_count) - - if sidebar_menu.has_renderable_items? + - if sidebar_menu.has_items? %li.divider.fly-out-top-item = render partial: 'shared/nav/sidebar_menu_item', collection: sidebar_menu.renderable_items diff --git a/app/views/shared/runners/show.html.haml b/app/views/shared/runners/_runner_details.html.haml index 757ec870f79..672f0b6a83f 100644 --- a/app/views/shared/runners/show.html.haml +++ b/app/views/shared/runners/_runner_details.html.haml @@ -1,8 +1,9 @@ -- page_title "#{@runner.description} ##{@runner.id}", _("Runners") +- breadcrumb_title runner.short_sha +- page_title "##{runner.id} (#{runner.short_sha})" %h2.page-title - = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id }) - = render 'shared/runners/runner_type_badge', runner: @runner + = s_('Runners|Runner #%{runner_id}' % { runner_id: runner.id }) + = render 'shared/runners/runner_type_badge', runner: runner .table-holder %table.table @@ -12,51 +13,51 @@ %th= s_('Runners|Value') %tr %td= s_('Runners|Active') - %td= @runner.active? ? _('Yes') : _('No') + %td= runner.active? ? _('Yes') : _('No') %tr %td= s_('Runners|Protected') - %td= @runner.ref_protected? ? _('Yes') : _('No') + %td= runner.ref_protected? ? _('Yes') : _('No') %tr %td= s_('Runners|Can run untagged jobs') - %td= @runner.run_untagged? ? _('Yes') : _('No') - - unless @runner.group_type? + %td= runner.run_untagged? ? _('Yes') : _('No') + - unless runner.group_type? %tr %td= s_('Runners|Locked to this project') - %td= @runner.locked? ? _('Yes') : _('No') + %td= runner.locked? ? _('Yes') : _('No') %tr %td= s_('Runners|Tags') %td - - @runner.tag_list.sort.each do |tag| + - runner.tag_list.sort.each do |tag| %span.badge.badge-primary = tag %tr %td= s_('Runners|Name') - %td= @runner.name + %td= runner.name %tr %td= s_('Runners|Version') - %td= @runner.version + %td= runner.version %tr %td= s_('Runners|IP Address') - %td= @runner.ip_address + %td= runner.ip_address %tr %td= s_('Runners|Revision') - %td= @runner.revision + %td= runner.revision %tr %td= s_('Runners|Platform') - %td= @runner.platform + %td= runner.platform %tr %td= s_('Runners|Architecture') - %td= @runner.architecture + %td= runner.architecture %tr %td= s_('Runners|Description') - %td= @runner.description + %td= runner.description %tr %td= s_('Runners|Maximum job timeout') - %td= @runner.maximum_timeout_human_readable + %td= runner.maximum_timeout_human_readable %tr %td= s_('Runners|Last contact') %td - - if @runner.contacted_at - = time_ago_with_tooltip @runner.contacted_at + - if runner.contacted_at + = time_ago_with_tooltip runner.contacted_at - else = s_('Never') diff --git a/app/views/shared/runners/_shared_runners_description.html.haml b/app/views/shared/runners/_shared_runners_description.html.haml index 92564ec48bd..a276f725576 100644 --- a/app/views/shared/runners/_shared_runners_description.html.haml +++ b/app/views/shared/runners/_shared_runners_description.html.haml @@ -3,11 +3,9 @@ %h4 = _('Shared runners') -.bs-callout.shared-runners-description - = _('These runners are shared across this GitLab instance.') - %p +.bs-callout{ data: { testid: 'shared-runners-description' } } + %p= _('These runners are shared across this GitLab instance.') - if Gitlab::CurrentSettings.shared_runners_text.present? - = markdown_field(Gitlab::CurrentSettings.current_application_settings, :shared_runners_text) + = markdown(Gitlab::CurrentSettings.current_application_settings.shared_runners_text) - else - = _('The same shared runner executes code from multiple projects, unless you configure autoscaling with %{link} set to 1 (which it is on GitLab.com).').html_safe % { link: link } - = yield + %p= _('The same shared runner executes code from multiple projects, unless you configure autoscaling with %{link} set to 1 (which it is on GitLab.com).').html_safe % { link: link } diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 52cf0248f21..4e373dda013 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -8,7 +8,7 @@ = link_to gitlab_snippet_path(snippet) do = snippet.title - %ul.controls + %ul.controls{ data: { qa_selector: 'snippet_file_count_content', qa_snippet_files: snippet.statistics&.file_count } } %li = snippet_file_count(snippet) %li @@ -16,7 +16,7 @@ = sprite_icon('comments', css_class: 'gl-vertical-align-text-bottom') = notes_count %li - %span.sr-only + %span.sr-only{ data: { qa_selector: 'snippet_visibility_content', qa_snippet_visibility: visibility_level_label(snippet.visibility_level) } } = visibility_level_label(snippet.visibility_level) = visibility_level_icon(snippet.visibility_level) diff --git a/app/views/shared/ssh_keys/_key_delete.html.haml b/app/views/shared/ssh_keys/_key_delete.html.haml index 1526e5d3eda..f8bb0e21f67 100644 --- a/app/views/shared/ssh_keys/_key_delete.html.haml +++ b/app/views/shared/ssh_keys/_key_delete.html.haml @@ -1,6 +1,9 @@ +- title = _('Delete Key') +- aria = { label: title } + - if defined?(text) - = button_to text, '#', class: html_class, data: button_data + = button_to text, '#', class: html_class, data: button_data, title: title, aria: aria - else - = button_to '#', class: html_class, data: button_data do + = button_to '#', class: html_class, data: button_data, title: title, aria: aria do %span.sr-only= _('Delete') = sprite_icon('remove') diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml index 1c8e300fa8a..33e95446bd7 100644 --- a/app/views/shared/tokens/_scopes_form.html.haml +++ b/app/views/shared/tokens/_scopes_form.html.haml @@ -4,6 +4,6 @@ - scopes.each do |scope| %fieldset.form-group.form-check - = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: "form-check-input qa-#{scope}-radio" + = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: "form-check-input", data: { qa_selector: "#{scope}_checkbox" } = label_tag "#{prefix}_scopes_#{scope}", scope, class: 'label-bold form-check-label' .text-secondary= t scope, scope: scope_description(prefix) diff --git a/app/views/shared/users/_user.html.haml b/app/views/shared/users/_user.html.haml index f92c12102bb..7f7cd31591e 100644 --- a/app/views/shared/users/_user.html.haml +++ b/app/views/shared/users/_user.html.haml @@ -7,7 +7,7 @@ .user-info .block-truncated - = link_to user.name, user_path(user), class: 'user js-user-link', data: { user_id: user.id } + = link_to user.name, user_path(user), class: 'user js-user-link', data: { user_id: user.id, qa_selector: 'user_link', qa_username: user.username } .block-truncated %span.gl-text-gray-900= user.to_reference diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index ad84ce1d343..18912bf149f 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -43,13 +43,13 @@ = form.label :issues_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Issues events') %p.text-muted.gl-ml-1 - = s_('Webhooks|URL is triggered when an issue is created, updated, or merged') + = s_('Webhooks|URL is triggered when an issue is created, updated, closed, or reopened') %li = form.check_box :confidential_issues_events, class: 'form-check-input' = form.label :confidential_issues_events, class: 'list-label form-check-label gl-ml-1' do %strong= s_('Webhooks|Confidential issues events') %p.text-muted.gl-ml-1 - = s_('Webhooks|URL is triggered when a confidential issue is created, updated, or merged') + = s_('Webhooks|URL is triggered when a confidential issue is created, updated, closed, or reopened') - if @group = render_if_exists 'groups/hooks/member_events', form: form = render_if_exists 'groups/hooks/subgroup_events', form: form diff --git a/app/views/shared/wikis/history.html.haml b/app/views/shared/wikis/history.html.haml index 079b9768730..afbed3b0f42 100644 --- a/app/views/shared/wikis/history.html.haml +++ b/app/views/shared/wikis/history.html.haml @@ -20,7 +20,7 @@ %th= _('Changes') %th= _('Last updated') %tbody - - @page_versions.each do |commit| + - @commits.each do |commit| %tr %td = link_to wiki_page_path(@wiki, @page, version_id: commit.id) do @@ -33,6 +33,6 @@ = commit.message %td = time_ago_with_tooltip(commit.authored_date) - = paginate @page_versions, theme: 'gitlab' + = paginate @commits, theme: 'gitlab' = render 'shared/wikis/sidebar' diff --git a/app/views/sherlock/queries/show.html.haml b/app/views/sherlock/queries/show.html.haml index e4a48943115..eea13d105d8 100644 --- a/app/views/sherlock/queries/show.html.haml +++ b/app/views/sherlock/queries/show.html.haml @@ -11,7 +11,7 @@ .row-content-block .float-right - = link_to(sherlock_transaction_path(@transaction), class: 'btn') do + = link_to(sherlock_transaction_path(@transaction), class: 'btn gl-button btn-default') do = sprite_icon('arrow-left') = t('sherlock.transaction') .oneline diff --git a/app/views/snippets/_snippets_scope_menu.html.haml b/app/views/snippets/_snippets_scope_menu.html.haml index e9c9ca6e856..ac6dac8b322 100644 --- a/app/views/snippets/_snippets_scope_menu.html.haml +++ b/app/views/snippets/_snippets_scope_menu.html.haml @@ -5,7 +5,7 @@ %li{ class: active_when(params[:scope].nil?) } = link_to subject_snippets_path(subject) do = _("All") - %span.badge.badge-pill + %span.badge.badge-muted.badge-pill.gl-badge.sm - if include_private = counts[:total] - else @@ -15,17 +15,17 @@ %li{ class: active_when(params[:scope] == "are_private") } = link_to subject_snippets_path(subject, scope: 'are_private') do = _("Private") - %span.badge.badge-pill + %span.badge.badge-muted.badge-pill.gl-badge.sm = counts[:are_private] %li{ class: active_when(params[:scope] == "are_internal") } = link_to subject_snippets_path(subject, scope: 'are_internal') do = _("Internal") - %span.badge.badge-pill + %span.badge.badge-muted.badge-pill.gl-badge.sm = counts[:are_internal] %li{ class: active_when(params[:scope] == "are_public") } = link_to subject_snippets_path(subject, scope: 'are_public') do = _("Public") - %span.badge.badge-pill + %span.badge.badge-muted.badge-pill.gl-badge.sm = counts[:are_public] diff --git a/app/views/snippets/edit.html.haml b/app/views/snippets/edit.html.haml index 66f5e8148e1..f737e347c39 100644 --- a/app/views/snippets/edit.html.haml +++ b/app/views/snippets/edit.html.haml @@ -1,5 +1,7 @@ - page_title _("Edit"), "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") - @content_class = "limit-container-width" unless fluid_layout +- content_for :prefetch_asset_tags do + - webpack_preload_asset_tag('monaco') %h3.page-title = _("Edit Snippet") diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml index beb4cf4a6aa..4fdb9e70742 100644 --- a/app/views/snippets/show.html.haml +++ b/app/views/snippets/show.html.haml @@ -9,6 +9,8 @@ - add_to_breadcrumbs _("Snippets"), dashboard_snippets_path - breadcrumb_title @snippet.to_reference - page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") +- content_for :prefetch_asset_tags do + - webpack_preload_asset_tag('monaco', prefetch: true) #js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} } diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index a78971967ff..2e6d335a98d 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -3,7 +3,7 @@ .row.d-none.d-sm-flex .col-12.calendar-block.gl-my-3 .user-calendar.light{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: Time.zone.utc_offset } } - .spinner.spinner-md.gl-my-8 + .gl-spinner.gl-spinner-md.gl-my-8 .user-calendar-error.invisible = _('There was an error loading users activity calendar.') %a.js-retry-load{ href: '#' } @@ -18,9 +18,9 @@ %h4.gl-flex-grow-1 = Feature.enabled?(:security_auto_fix) && @user.bot? ? s_('UserProfile|Bot activity') : s_('UserProfile|Activity') = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all" - .overview-content-list{ data: { href: user_activity_path } } + .overview-content-list{ data: { href: user_activity_path, qa_selector: 'user_activity_content' } } .center.light.loading - .spinner.spinner-md + .gl-spinner.gl-spinner-md - unless Feature.enabled?(:security_auto_fix) && @user.bot? .col-md-12.col-lg-6 @@ -32,4 +32,4 @@ = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all" .overview-content-list{ data: { href: user_projects_path } } .center.light.loading - .spinner.spinner-md + .gl-spinner.gl-spinner-md diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index daa41e0ebfe..a5b95883361 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -39,7 +39,7 @@ = link_to user_unfollow_path(@user, :json) , class: link_classes + 'btn gl-button btn-default', method: :post do = _('Unfollow') - else - = link_to user_follow_path(@user, :json) , class: link_classes + 'btn gl-button btn-confirm', method: :post do + = link_to user_follow_path(@user, :json) , class: link_classes + 'btn gl-button btn-confirm', method: :post, data: { qa_selector: 'follow_user_link' } do = _('Follow') .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] } @@ -103,7 +103,7 @@ - count = @user.followers.count = n_('1 follower', '%{count} followers', count) % { count: count } .profile-link-holder.middle-dot-divider - = link_to user_following_path, class: 'text-link' do + = link_to user_following_path, class: 'text-link', data: { qa_selector: 'following_link' } do = @user.followees.count = _('following') - if @user.bio.present? @@ -169,7 +169,7 @@ = s_('UserProfile|Most Recent Activity') .content_list{ data: { href: user_activity_path } } .loading - .spinner.spinner-md + .gl-spinner.gl-spinner-md - unless @user.bot? - if profile_tab?(:groups) #groups.tab-pane @@ -200,7 +200,7 @@ -# This tab is always loaded via AJAX .loading.hide - .spinner.spinner-md + .gl-spinner.gl-spinner-md - if profile_tabs.empty? .svg-content diff --git a/app/workers/admin_email_worker.rb b/app/workers/admin_email_worker.rb index 8d589c03259..ea7709c649f 100644 --- a/app/workers/admin_email_worker.rb +++ b/app/workers/admin_email_worker.rb @@ -2,6 +2,8 @@ class AdminEmailWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index fa6ea54e342..07c1ce0d939 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -4,6 +4,7 @@ # Do not edit it manually! --- - :name: authorized_project_update:authorized_project_update_project_create + :worker_name: AuthorizedProjectUpdate::ProjectCreateWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low @@ -12,6 +13,7 @@ :idempotent: true :tags: [] - :name: authorized_project_update:authorized_project_update_project_group_link_create + :worker_name: AuthorizedProjectUpdate::ProjectGroupLinkCreateWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low @@ -20,6 +22,7 @@ :idempotent: true :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_over_user_range + :worker_name: AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low @@ -28,6 +31,7 @@ :idempotent: :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency + :worker_name: AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low @@ -36,6 +40,7 @@ :idempotent: true :tags: [] - :name: auto_devops:auto_devops_disable + :worker_name: AutoDevops::DisableWorker :feature_category: :auto_devops :has_external_dependencies: :urgency: :low @@ -44,6 +49,7 @@ :idempotent: :tags: [] - :name: auto_merge:auto_merge_process + :worker_name: AutoMergeProcessWorker :feature_category: :continuous_delivery :has_external_dependencies: :urgency: :low @@ -52,46 +58,57 @@ :idempotent: :tags: [] - :name: chaos:chaos_cpu_spin + :worker_name: Chaos::CpuSpinWorker :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 :idempotent: - :tags: [] + :tags: + - :exclude_from_gitlab_com - :name: chaos:chaos_db_spin + :worker_name: Chaos::DbSpinWorker :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 :idempotent: - :tags: [] + :tags: + - :exclude_from_gitlab_com - :name: chaos:chaos_kill + :worker_name: Chaos::KillWorker :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 :idempotent: - :tags: [] + :tags: + - :exclude_from_gitlab_com - :name: chaos:chaos_leak_mem + :worker_name: Chaos::LeakMemWorker :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 :idempotent: - :tags: [] + :tags: + - :exclude_from_gitlab_com - :name: chaos:chaos_sleep + :worker_name: Chaos::SleepWorker :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 :idempotent: - :tags: [] + :tags: + - :exclude_from_gitlab_com - :name: container_repository:cleanup_container_repository + :worker_name: CleanupContainerRepositoryWorker :feature_category: :container_registry :has_external_dependencies: :urgency: :low @@ -100,14 +117,17 @@ :idempotent: true :tags: [] - :name: container_repository:container_expiration_policies_cleanup_container_repository + :worker_name: ContainerExpirationPolicies::CleanupContainerRepositoryWorker :feature_category: :container_registry :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: container_repository:delete_container_repository + :worker_name: DeleteContainerRepositoryWorker :feature_category: :container_registry :has_external_dependencies: :urgency: :low @@ -116,6 +136,7 @@ :idempotent: :tags: [] - :name: cronjob:admin_email + :worker_name: AdminEmailWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -124,22 +145,27 @@ :idempotent: :tags: [] - :name: cronjob:analytics_instance_statistics_count_job_trigger + :worker_name: Analytics::InstanceStatistics::CountJobTriggerWorker :feature_category: :devops_reports :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:analytics_usage_trends_count_job_trigger + :worker_name: Analytics::UsageTrends::CountJobTriggerWorker :feature_category: :devops_reports :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:authorized_project_update_periodic_recalculate + :worker_name: AuthorizedProjectUpdate::PeriodicRecalculateWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -148,6 +174,7 @@ :idempotent: true :tags: [] - :name: cronjob:ci_archive_traces_cron + :worker_name: Ci::ArchiveTracesCronWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -155,7 +182,8 @@ :weight: 1 :idempotent: :tags: [] -- :name: cronjob:ci_pipeline_artifacts_expire_artifacts +- :name: cronjob:ci_delete_unit_tests + :worker_name: Ci::DeleteUnitTestsWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -163,7 +191,18 @@ :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:ci_pipeline_artifacts_expire_artifacts + :worker_name: Ci::PipelineArtifacts::ExpireArtifactsWorker + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: + - :exclude_from_kubernetes - :name: cronjob:ci_platform_metrics_update_cron + :worker_name: CiPlatformMetricsUpdateCronWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -172,14 +211,17 @@ :idempotent: :tags: [] - :name: cronjob:ci_schedule_delete_objects_cron + :worker_name: Ci::ScheduleDeleteObjectsCronWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:container_expiration_policy + :worker_name: ContainerExpirationPolicyWorker :feature_category: :container_registry :has_external_dependencies: :urgency: :low @@ -188,14 +230,17 @@ :idempotent: :tags: [] - :name: cronjob:database_batched_background_migration + :worker_name: Database::BatchedBackgroundMigrationWorker :feature_category: :database :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:environments_auto_stop_cron + :worker_name: Environments::AutoStopCronWorker :feature_category: :continuous_delivery :has_external_dependencies: :urgency: :low @@ -204,6 +249,7 @@ :idempotent: :tags: [] - :name: cronjob:expire_build_artifacts + :worker_name: ExpireBuildArtifactsWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -212,6 +258,7 @@ :idempotent: :tags: [] - :name: cronjob:gitlab_usage_ping + :worker_name: GitlabUsagePingWorker :feature_category: :usage_ping :has_external_dependencies: :urgency: :low @@ -220,6 +267,7 @@ :idempotent: :tags: [] - :name: cronjob:import_export_project_cleanup + :worker_name: ImportExportProjectCleanupWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -228,6 +276,7 @@ :idempotent: :tags: [] - :name: cronjob:import_stuck_project_import_jobs + :worker_name: Gitlab::Import::StuckProjectImportJobsWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -236,6 +285,7 @@ :idempotent: :tags: [] - :name: cronjob:issue_due_scheduler + :worker_name: IssueDueSchedulerWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low @@ -244,6 +294,7 @@ :idempotent: :tags: [] - :name: cronjob:jira_import_stuck_jira_import_jobs + :worker_name: Gitlab::JiraImport::StuckJiraImportJobsWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -252,14 +303,17 @@ :idempotent: :tags: [] - :name: cronjob:member_invitation_reminder_emails + :worker_name: MemberInvitationReminderEmailsWorker :feature_category: :subgroups :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:metrics_dashboard_schedule_annotations_prune + :worker_name: Metrics::Dashboard::ScheduleAnnotationsPruneWorker :feature_category: :metrics :has_external_dependencies: :urgency: :low @@ -268,14 +322,17 @@ :idempotent: true :tags: [] - :name: cronjob:namespaces_in_product_marketing_emails + :worker_name: Namespaces::InProductMarketingEmailsWorker :feature_category: :subgroups :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:namespaces_prune_aggregation_schedules + :worker_name: Namespaces::PruneAggregationSchedulesWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -284,14 +341,17 @@ :idempotent: :tags: [] - :name: cronjob:packages_composer_cache_cleanup + :worker_name: Packages::Composer::CacheCleanupWorker :feature_category: :package_registry :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:pages_domain_removal_cron + :worker_name: PagesDomainRemovalCronWorker :feature_category: :pages :has_external_dependencies: :urgency: :low @@ -300,6 +360,7 @@ :idempotent: :tags: [] - :name: cronjob:pages_domain_ssl_renewal_cron + :worker_name: PagesDomainSslRenewalCronWorker :feature_category: :pages :has_external_dependencies: :urgency: :low @@ -308,6 +369,7 @@ :idempotent: :tags: [] - :name: cronjob:pages_domain_verification_cron + :worker_name: PagesDomainVerificationCronWorker :feature_category: :pages :has_external_dependencies: :urgency: :low @@ -316,6 +378,7 @@ :idempotent: :tags: [] - :name: cronjob:partition_creation + :worker_name: PartitionCreationWorker :feature_category: :database :has_external_dependencies: :urgency: :low @@ -324,14 +387,17 @@ :idempotent: true :tags: [] - :name: cronjob:personal_access_tokens_expired_notification + :worker_name: PersonalAccessTokens::ExpiredNotificationWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:personal_access_tokens_expiring + :worker_name: PersonalAccessTokens::ExpiringWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low @@ -340,6 +406,7 @@ :idempotent: :tags: [] - :name: cronjob:pipeline_schedule + :worker_name: PipelineScheduleWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -348,6 +415,7 @@ :idempotent: :tags: [] - :name: cronjob:prune_old_events + :worker_name: PruneOldEventsWorker :feature_category: :users :has_external_dependencies: :urgency: :low @@ -356,6 +424,7 @@ :idempotent: :tags: [] - :name: cronjob:prune_web_hook_logs + :worker_name: PruneWebHookLogsWorker :feature_category: :integrations :has_external_dependencies: :urgency: :low @@ -364,14 +433,17 @@ :idempotent: :tags: [] - :name: cronjob:releases_manage_evidence + :worker_name: Releases::ManageEvidenceWorker :feature_category: :release_evidence :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:remove_expired_group_links + :worker_name: RemoveExpiredGroupLinksWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low @@ -380,6 +452,7 @@ :idempotent: :tags: [] - :name: cronjob:remove_expired_members + :worker_name: RemoveExpiredMembersWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low @@ -388,14 +461,17 @@ :idempotent: :tags: [] - :name: cronjob:remove_unaccepted_member_invites + :worker_name: RemoveUnacceptedMemberInvitesWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:remove_unreferenced_lfs_objects + :worker_name: RemoveUnreferencedLfsObjectsWorker :feature_category: :git_lfs :has_external_dependencies: :urgency: :low @@ -404,6 +480,7 @@ :idempotent: :tags: [] - :name: cronjob:repository_archive_cache + :worker_name: RepositoryArchiveCacheWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -412,6 +489,7 @@ :idempotent: :tags: [] - :name: cronjob:repository_check_dispatch + :worker_name: RepositoryCheck::DispatchWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -420,6 +498,7 @@ :idempotent: :tags: [] - :name: cronjob:requests_profiles + :worker_name: RequestsProfilesWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -428,14 +507,17 @@ :idempotent: :tags: [] - :name: cronjob:schedule_merge_request_cleanup_refs + :worker_name: ScheduleMergeRequestCleanupRefsWorker :feature_category: :code_review :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:schedule_migrate_external_diffs + :worker_name: ScheduleMigrateExternalDiffsWorker :feature_category: :code_review :has_external_dependencies: :urgency: :low @@ -444,22 +526,27 @@ :idempotent: :tags: [] - :name: cronjob:ssh_keys_expired_notification + :worker_name: SshKeys::ExpiredNotificationWorker :feature_category: :compliance_management :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:ssh_keys_expiring_soon_notification + :worker_name: SshKeys::ExpiringSoonNotificationWorker :feature_category: :compliance_management :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:stuck_ci_jobs + :worker_name: StuckCiJobsWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -468,6 +555,7 @@ :idempotent: :tags: [] - :name: cronjob:stuck_export_jobs + :worker_name: StuckExportJobsWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -476,6 +564,7 @@ :idempotent: :tags: [] - :name: cronjob:stuck_merge_jobs + :worker_name: StuckMergeJobsWorker :feature_category: :code_review :has_external_dependencies: :urgency: :low @@ -484,6 +573,7 @@ :idempotent: :tags: [] - :name: cronjob:trending_projects + :worker_name: TrendingProjectsWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -492,6 +582,7 @@ :idempotent: :tags: [] - :name: cronjob:update_container_registry_info + :worker_name: UpdateContainerRegistryInfoWorker :feature_category: :container_registry :has_external_dependencies: :urgency: :low @@ -500,14 +591,17 @@ :idempotent: true :tags: [] - :name: cronjob:user_status_cleanup_batch + :worker_name: UserStatusCleanup::BatchWorker :feature_category: :users :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: cronjob:users_create_statistics + :worker_name: Users::CreateStatisticsWorker :feature_category: :users :has_external_dependencies: :urgency: :low @@ -515,7 +609,18 @@ :weight: 1 :idempotent: :tags: [] +- :name: cronjob:users_deactivate_dormant_users + :worker_name: Users::DeactivateDormantUsersWorker + :feature_category: :utilization + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: + - :exclude_from_kubernetes - :name: cronjob:x509_issuer_crl_check + :worker_name: X509IssuerCrlCheckWorker :feature_category: :source_code_management :has_external_dependencies: true :urgency: :low @@ -524,6 +629,7 @@ :idempotent: true :tags: [] - :name: dependency_proxy:purge_dependency_proxy_cache + :worker_name: PurgeDependencyProxyCacheWorker :feature_category: :dependency_proxy :has_external_dependencies: :urgency: :low @@ -532,14 +638,17 @@ :idempotent: true :tags: [] - :name: deployment:deployments_drop_older_deployments + :worker_name: Deployments::DropOlderDeploymentsWorker :feature_category: :continuous_delivery :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 3 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: deployment:deployments_execute_hooks + :worker_name: Deployments::ExecuteHooksWorker :feature_category: :continuous_delivery :has_external_dependencies: :urgency: :low @@ -548,6 +657,7 @@ :idempotent: :tags: [] - :name: deployment:deployments_finished + :worker_name: Deployments::FinishedWorker :feature_category: :continuous_delivery :has_external_dependencies: :urgency: :low @@ -556,6 +666,16 @@ :idempotent: :tags: [] - :name: deployment:deployments_forward_deployment + :worker_name: Deployments::ForwardDeploymentWorker + :feature_category: :continuous_delivery + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 3 + :idempotent: + :tags: [] +- :name: deployment:deployments_hooks + :worker_name: Deployments::HooksWorker :feature_category: :continuous_delivery :has_external_dependencies: :urgency: :low @@ -564,6 +684,7 @@ :idempotent: :tags: [] - :name: deployment:deployments_link_merge_request + :worker_name: Deployments::LinkMergeRequestWorker :feature_category: :continuous_delivery :has_external_dependencies: :urgency: :low @@ -572,6 +693,7 @@ :idempotent: true :tags: [] - :name: deployment:deployments_success + :worker_name: Deployments::SuccessWorker :feature_category: :continuous_delivery :has_external_dependencies: :urgency: :low @@ -580,6 +702,7 @@ :idempotent: :tags: [] - :name: deployment:deployments_update_environment + :worker_name: Deployments::UpdateEnvironmentWorker :feature_category: :continuous_delivery :has_external_dependencies: :urgency: :low @@ -588,6 +711,7 @@ :idempotent: true :tags: [] - :name: gcp_cluster:cluster_configure_istio + :worker_name: ClusterConfigureIstioWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -596,6 +720,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:cluster_install_app + :worker_name: ClusterInstallAppWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -604,6 +729,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:cluster_patch_app + :worker_name: ClusterPatchAppWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -612,6 +738,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:cluster_provision + :worker_name: ClusterProvisionWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -620,6 +747,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:cluster_update_app + :worker_name: ClusterUpdateAppWorker :feature_category: :kubernetes_management :has_external_dependencies: :urgency: :low @@ -628,6 +756,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:cluster_upgrade_app + :worker_name: ClusterUpgradeAppWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -636,6 +765,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:cluster_wait_for_app_installation + :worker_name: ClusterWaitForAppInstallationWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -644,6 +774,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:cluster_wait_for_app_update + :worker_name: ClusterWaitForAppUpdateWorker :feature_category: :kubernetes_management :has_external_dependencies: :urgency: :low @@ -652,6 +783,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:cluster_wait_for_ingress_ip_address + :worker_name: ClusterWaitForIngressIpAddressWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -660,6 +792,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_activate_service + :worker_name: Clusters::Applications::ActivateServiceWorker :feature_category: :kubernetes_management :has_external_dependencies: :urgency: :low @@ -668,6 +801,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_deactivate_service + :worker_name: Clusters::Applications::DeactivateServiceWorker :feature_category: :kubernetes_management :has_external_dependencies: :urgency: :low @@ -676,6 +810,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_uninstall + :worker_name: Clusters::Applications::UninstallWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -684,6 +819,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_wait_for_uninstall_app + :worker_name: Clusters::Applications::WaitForUninstallAppWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -692,6 +828,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:clusters_cleanup_app + :worker_name: Clusters::Cleanup::AppWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -700,6 +837,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:clusters_cleanup_project_namespace + :worker_name: Clusters::Cleanup::ProjectNamespaceWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -708,6 +846,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:clusters_cleanup_service_account + :worker_name: Clusters::Cleanup::ServiceAccountWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -716,6 +855,7 @@ :idempotent: :tags: [] - :name: gcp_cluster:wait_for_cluster_creation + :worker_name: WaitForClusterCreationWorker :feature_category: :kubernetes_management :has_external_dependencies: true :urgency: :low @@ -724,6 +864,7 @@ :idempotent: :tags: [] - :name: github_importer:github_import_import_diff_note + :worker_name: Gitlab::GithubImport::ImportDiffNoteWorker :feature_category: :importers :has_external_dependencies: true :urgency: :low @@ -732,6 +873,7 @@ :idempotent: :tags: [] - :name: github_importer:github_import_import_issue + :worker_name: Gitlab::GithubImport::ImportIssueWorker :feature_category: :importers :has_external_dependencies: true :urgency: :low @@ -740,6 +882,7 @@ :idempotent: :tags: [] - :name: github_importer:github_import_import_lfs_object + :worker_name: Gitlab::GithubImport::ImportLfsObjectWorker :feature_category: :importers :has_external_dependencies: true :urgency: :low @@ -748,6 +891,7 @@ :idempotent: :tags: [] - :name: github_importer:github_import_import_note + :worker_name: Gitlab::GithubImport::ImportNoteWorker :feature_category: :importers :has_external_dependencies: true :urgency: :low @@ -756,6 +900,7 @@ :idempotent: :tags: [] - :name: github_importer:github_import_import_pull_request + :worker_name: Gitlab::GithubImport::ImportPullRequestWorker :feature_category: :importers :has_external_dependencies: true :urgency: :low @@ -764,22 +909,27 @@ :idempotent: :tags: [] - :name: github_importer:github_import_import_pull_request_merged_by + :worker_name: Gitlab::GithubImport::ImportPullRequestMergedByWorker :feature_category: :importers :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: github_importer:github_import_import_pull_request_review + :worker_name: Gitlab::GithubImport::ImportPullRequestReviewWorker :feature_category: :importers :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: github_importer:github_import_refresh_import_jid + :worker_name: Gitlab::GithubImport::RefreshImportJidWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -788,6 +938,7 @@ :idempotent: :tags: [] - :name: github_importer:github_import_stage_finish_import + :worker_name: Gitlab::GithubImport::Stage::FinishImportWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -796,6 +947,7 @@ :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_base_data + :worker_name: Gitlab::GithubImport::Stage::ImportBaseDataWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -804,6 +956,7 @@ :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_issues_and_diff_notes + :worker_name: Gitlab::GithubImport::Stage::ImportIssuesAndDiffNotesWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -812,6 +965,7 @@ :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_lfs_objects + :worker_name: Gitlab::GithubImport::Stage::ImportLfsObjectsWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -820,6 +974,7 @@ :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_notes + :worker_name: Gitlab::GithubImport::Stage::ImportNotesWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -828,6 +983,7 @@ :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_pull_requests + :worker_name: Gitlab::GithubImport::Stage::ImportPullRequestsWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -836,22 +992,27 @@ :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_pull_requests_merged_by + :worker_name: Gitlab::GithubImport::Stage::ImportPullRequestsMergedByWorker :feature_category: :importers :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: github_importer:github_import_stage_import_pull_requests_reviews + :worker_name: Gitlab::GithubImport::Stage::ImportPullRequestsReviewsWorker :feature_category: :importers :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: github_importer:github_import_stage_import_repository + :worker_name: Gitlab::GithubImport::Stage::ImportRepositoryWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -860,38 +1021,47 @@ :idempotent: :tags: [] - :name: hashed_storage:hashed_storage_migrator + :worker_name: HashedStorage::MigratorWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_gitlab_com - :name: hashed_storage:hashed_storage_project_migrate + :worker_name: HashedStorage::ProjectMigrateWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_gitlab_com - :name: hashed_storage:hashed_storage_project_rollback + :worker_name: HashedStorage::ProjectRollbackWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_gitlab_com - :name: hashed_storage:hashed_storage_rollbacker + :worker_name: HashedStorage::RollbackerWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_gitlab_com - :name: incident_management:clusters_applications_check_prometheus_health + :worker_name: Clusters::Applications::CheckPrometheusHealthWorker :feature_category: :incident_management :has_external_dependencies: true :urgency: :low @@ -900,14 +1070,17 @@ :idempotent: true :tags: [] - :name: incident_management:incident_management_add_severity_system_note + :worker_name: IncidentManagement::AddSeveritySystemNoteWorker :feature_category: :incident_management :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: incident_management:incident_management_pager_duty_process_incident + :worker_name: IncidentManagement::PagerDuty::ProcessIncidentWorker :feature_category: :incident_management :has_external_dependencies: :urgency: :low @@ -916,6 +1089,7 @@ :idempotent: :tags: [] - :name: incident_management:incident_management_process_alert + :worker_name: IncidentManagement::ProcessAlertWorker :feature_category: :incident_management :has_external_dependencies: :urgency: :low @@ -923,7 +1097,17 @@ :weight: 2 :idempotent: :tags: [] +- :name: incident_management:incident_management_process_alert_worker_v2 + :worker_name: IncidentManagement::ProcessAlertWorkerV2 + :feature_category: :incident_management + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 2 + :idempotent: true + :tags: [] - :name: incident_management:incident_management_process_prometheus_alert + :worker_name: IncidentManagement::ProcessPrometheusAlertWorker :feature_category: :incident_management :has_external_dependencies: :urgency: :low @@ -932,6 +1116,7 @@ :idempotent: :tags: [] - :name: jira_connect:jira_connect_sync_branch + :worker_name: JiraConnect::SyncBranchWorker :feature_category: :integrations :has_external_dependencies: true :urgency: :low @@ -940,30 +1125,37 @@ :idempotent: true :tags: [] - :name: jira_connect:jira_connect_sync_builds + :worker_name: JiraConnect::SyncBuildsWorker :feature_category: :integrations :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: jira_connect:jira_connect_sync_deployments + :worker_name: JiraConnect::SyncDeploymentsWorker :feature_category: :integrations :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: jira_connect:jira_connect_sync_feature_flags + :worker_name: JiraConnect::SyncFeatureFlagsWorker :feature_category: :integrations :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: jira_connect:jira_connect_sync_merge_request + :worker_name: JiraConnect::SyncMergeRequestWorker :feature_category: :integrations :has_external_dependencies: true :urgency: :low @@ -972,14 +1164,17 @@ :idempotent: true :tags: [] - :name: jira_connect:jira_connect_sync_project + :worker_name: JiraConnect::SyncProjectWorker :feature_category: :integrations :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: jira_importer:jira_import_advance_stage + :worker_name: Gitlab::JiraImport::AdvanceStageWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -988,6 +1183,7 @@ :idempotent: :tags: [] - :name: jira_importer:jira_import_import_issue + :worker_name: Gitlab::JiraImport::ImportIssueWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -996,6 +1192,7 @@ :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_finish_import + :worker_name: Gitlab::JiraImport::Stage::FinishImportWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -1004,6 +1201,7 @@ :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_attachments + :worker_name: Gitlab::JiraImport::Stage::ImportAttachmentsWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -1012,6 +1210,7 @@ :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_issues + :worker_name: Gitlab::JiraImport::Stage::ImportIssuesWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -1020,6 +1219,7 @@ :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_labels + :worker_name: Gitlab::JiraImport::Stage::ImportLabelsWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -1028,6 +1228,7 @@ :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_notes + :worker_name: Gitlab::JiraImport::Stage::ImportNotesWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -1036,6 +1237,7 @@ :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_start_import + :worker_name: Gitlab::JiraImport::Stage::StartImportWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -1044,6 +1246,7 @@ :idempotent: :tags: [] - :name: mail_scheduler:mail_scheduler_issue_due + :worker_name: MailScheduler::IssueDueWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low @@ -1052,6 +1255,7 @@ :idempotent: :tags: [] - :name: mail_scheduler:mail_scheduler_notification_service + :worker_name: MailScheduler::NotificationServiceWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low @@ -1060,6 +1264,7 @@ :idempotent: :tags: [] - :name: object_pool:object_pool_create + :worker_name: ObjectPool::CreateWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :low @@ -1068,6 +1273,7 @@ :idempotent: :tags: [] - :name: object_pool:object_pool_destroy + :worker_name: ObjectPool::DestroyWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :low @@ -1076,6 +1282,7 @@ :idempotent: :tags: [] - :name: object_pool:object_pool_join + :worker_name: ObjectPool::JoinWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :low @@ -1084,6 +1291,7 @@ :idempotent: :tags: [] - :name: object_pool:object_pool_schedule_join + :worker_name: ObjectPool::ScheduleJoinWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :low @@ -1092,6 +1300,7 @@ :idempotent: :tags: [] - :name: object_storage:object_storage_background_move + :worker_name: ObjectStorage::BackgroundMoveWorker :feature_category: :not_owned :has_external_dependencies: :urgency: :low @@ -1100,6 +1309,7 @@ :idempotent: :tags: [] - :name: object_storage:object_storage_migrate_uploads + :worker_name: ObjectStorage::MigrateUploadsWorker :feature_category: :not_owned :has_external_dependencies: :urgency: :low @@ -1107,23 +1317,38 @@ :weight: 1 :idempotent: :tags: [] +- :name: package_repositories:packages_debian_process_changes + :worker_name: Packages::Debian::ProcessChangesWorker + :feature_category: :package_registry + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: + - :exclude_from_kubernetes - :name: package_repositories:packages_go_sync_packages + :worker_name: Packages::Go::SyncPackagesWorker :feature_category: :package_registry :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: package_repositories:packages_maven_metadata_sync + :worker_name: Packages::Maven::Metadata::SyncWorker :feature_category: :package_registry :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: package_repositories:packages_nuget_extraction + :worker_name: Packages::Nuget::ExtractionWorker :feature_category: :package_registry :has_external_dependencies: :urgency: :low @@ -1132,14 +1357,17 @@ :idempotent: :tags: [] - :name: package_repositories:packages_rubygems_extraction + :worker_name: Packages::Rubygems::ExtractionWorker :feature_category: :package_registry :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: true - :tags: [] + :idempotent: + :tags: + - :exclude_from_kubernetes - :name: pipeline_background:archive_trace + :worker_name: ArchiveTraceWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -1148,6 +1376,7 @@ :idempotent: :tags: [] - :name: pipeline_background:ci_build_trace_chunk_flush + :worker_name: Ci::BuildTraceChunkFlushWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -1156,6 +1385,7 @@ :idempotent: true :tags: [] - :name: pipeline_background:ci_daily_build_group_report_results + :worker_name: Ci::DailyBuildGroupReportResultsWorker :feature_category: :code_testing :has_external_dependencies: :urgency: :low @@ -1164,22 +1394,27 @@ :idempotent: true :tags: [] - :name: pipeline_background:ci_pipeline_artifacts_coverage_report + :worker_name: Ci::PipelineArtifacts::CoverageReportWorker :feature_category: :code_testing :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: pipeline_background:ci_pipeline_artifacts_create_quality_report + :worker_name: Ci::PipelineArtifacts::CreateQualityReportWorker :feature_category: :code_testing :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: pipeline_background:ci_pipeline_success_unlock_artifacts + :worker_name: Ci::PipelineSuccessUnlockArtifactsWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -1188,6 +1423,7 @@ :idempotent: true :tags: [] - :name: pipeline_background:ci_ref_delete_unlock_artifacts + :worker_name: Ci::RefDeleteUnlockArtifactsWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -1196,14 +1432,17 @@ :idempotent: true :tags: [] - :name: pipeline_background:ci_test_failure_history + :worker_name: Ci::TestFailureHistoryWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: pipeline_cache:expire_job_cache + :worker_name: ExpireJobCacheWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1212,6 +1451,7 @@ :idempotent: true :tags: [] - :name: pipeline_cache:expire_pipeline_cache + :worker_name: ExpirePipelineCacheWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1220,6 +1460,7 @@ :idempotent: true :tags: [] - :name: pipeline_creation:create_pipeline + :worker_name: CreatePipelineWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1228,6 +1469,7 @@ :idempotent: :tags: [] - :name: pipeline_creation:merge_requests_create_pipeline + :worker_name: MergeRequests::CreatePipelineWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1236,6 +1478,7 @@ :idempotent: true :tags: [] - :name: pipeline_creation:run_pipeline_schedule + :worker_name: RunPipelineScheduleWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -1244,6 +1487,7 @@ :idempotent: :tags: [] - :name: pipeline_default:ci_create_cross_project_pipeline + :worker_name: Ci::CreateCrossProjectPipelineWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -1252,22 +1496,36 @@ :idempotent: :tags: [] - :name: pipeline_default:ci_drop_pipeline + :worker_name: Ci::DropPipelineWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 3 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: pipeline_default:ci_merge_requests_add_todo_when_build_fails + :worker_name: Ci::MergeRequests::AddTodoWhenBuildFailsWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 3 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: pipeline_default:ci_pipeline_bridge_status + :worker_name: Ci::PipelineBridgeStatusWorker + :feature_category: :continuous_integration + :has_external_dependencies: + :urgency: :high + :resource_boundary: :cpu + :weight: 3 + :idempotent: + :tags: [] +- :name: pipeline_default:ci_retry_pipeline + :worker_name: Ci::RetryPipelineWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1276,6 +1534,7 @@ :idempotent: :tags: [] - :name: pipeline_default:pipeline_metrics + :worker_name: PipelineMetricsWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1284,6 +1543,7 @@ :idempotent: :tags: [] - :name: pipeline_default:pipeline_notification + :worker_name: PipelineNotificationWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1292,6 +1552,7 @@ :idempotent: :tags: [] - :name: pipeline_hooks:build_hooks + :worker_name: BuildHooksWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1300,6 +1561,7 @@ :idempotent: :tags: [] - :name: pipeline_hooks:pipeline_hooks + :worker_name: PipelineHooksWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1308,6 +1570,7 @@ :idempotent: :tags: [] - :name: pipeline_processing:build_finished + :worker_name: BuildFinishedWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1316,6 +1579,7 @@ :idempotent: :tags: [] - :name: pipeline_processing:build_queue + :worker_name: BuildQueueWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1324,6 +1588,7 @@ :idempotent: :tags: [] - :name: pipeline_processing:build_success + :worker_name: BuildSuccessWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1332,6 +1597,7 @@ :idempotent: :tags: [] - :name: pipeline_processing:ci_build_prepare + :worker_name: Ci::BuildPrepareWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -1340,6 +1606,7 @@ :idempotent: :tags: [] - :name: pipeline_processing:ci_build_schedule + :worker_name: Ci::BuildScheduleWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -1348,6 +1615,7 @@ :idempotent: :tags: [] - :name: pipeline_processing:ci_initial_pipeline_process + :worker_name: Ci::InitialPipelineProcessWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1356,6 +1624,7 @@ :idempotent: true :tags: [] - :name: pipeline_processing:ci_resource_groups_assign_resource_from_resource_group + :worker_name: Ci::ResourceGroups::AssignResourceFromResourceGroupWorker :feature_category: :continuous_delivery :has_external_dependencies: :urgency: :low @@ -1364,6 +1633,7 @@ :idempotent: :tags: [] - :name: pipeline_processing:pipeline_process + :worker_name: PipelineProcessWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1372,6 +1642,7 @@ :idempotent: :tags: [] - :name: pipeline_processing:pipeline_update + :worker_name: PipelineUpdateWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1380,6 +1651,7 @@ :idempotent: true :tags: [] - :name: pipeline_processing:stage_update + :worker_name: StageUpdateWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1388,6 +1660,7 @@ :idempotent: true :tags: [] - :name: pipeline_processing:update_head_pipeline_for_merge_request + :worker_name: UpdateHeadPipelineForMergeRequestWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :high @@ -1396,6 +1669,7 @@ :idempotent: true :tags: [] - :name: repository_check:repository_check_batch + :worker_name: RepositoryCheck::BatchWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -1404,6 +1678,7 @@ :idempotent: :tags: [] - :name: repository_check:repository_check_clear + :worker_name: RepositoryCheck::ClearWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -1412,6 +1687,7 @@ :idempotent: :tags: [] - :name: repository_check:repository_check_single_repository + :worker_name: RepositoryCheck::SingleRepositoryWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -1420,6 +1696,7 @@ :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_confidential_issue + :worker_name: TodosDestroyer::ConfidentialIssueWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low @@ -1428,14 +1705,17 @@ :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_destroyed_issuable + :worker_name: TodosDestroyer::DestroyedIssuableWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: todos_destroyer:todos_destroyer_entity_leave + :worker_name: TodosDestroyer::EntityLeaveWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low @@ -1444,6 +1724,7 @@ :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_group_private + :worker_name: TodosDestroyer::GroupPrivateWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low @@ -1452,6 +1733,7 @@ :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_private_features + :worker_name: TodosDestroyer::PrivateFeaturesWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low @@ -1460,6 +1742,7 @@ :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_project_private + :worker_name: TodosDestroyer::ProjectPrivateWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low @@ -1468,6 +1751,7 @@ :idempotent: :tags: [] - :name: unassign_issuables:members_destroyer_unassign_issuables + :worker_name: MembersDestroyer::UnassignIssuablesWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low @@ -1476,6 +1760,7 @@ :idempotent: true :tags: [] - :name: update_namespace_statistics:namespaces_root_statistics + :worker_name: Namespaces::RootStatisticsWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -1484,6 +1769,7 @@ :idempotent: true :tags: [] - :name: update_namespace_statistics:namespaces_schedule_aggregation + :worker_name: Namespaces::ScheduleAggregationWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -1492,30 +1778,37 @@ :idempotent: true :tags: [] - :name: analytics_instance_statistics_counter_job + :worker_name: Analytics::InstanceStatistics::CounterJobWorker :feature_category: :devops_reports :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: analytics_usage_trends_counter_job + :worker_name: Analytics::UsageTrends::CounterJobWorker :feature_category: :devops_reports :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: approve_blocked_pending_approval_users + :worker_name: ApproveBlockedPendingApprovalUsersWorker :feature_category: :users :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: authorized_keys + :worker_name: AuthorizedKeysWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :high @@ -1524,6 +1817,7 @@ :idempotent: true :tags: [] - :name: authorized_projects + :worker_name: AuthorizedProjectsWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :high @@ -1532,6 +1826,7 @@ :idempotent: true :tags: [] - :name: background_migration + :worker_name: BackgroundMigrationWorker :feature_category: :database :has_external_dependencies: :urgency: :throttled @@ -1540,30 +1835,56 @@ :idempotent: :tags: [] - :name: bulk_import + :worker_name: BulkImportWorker :feature_category: :importers :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: bulk_imports_entity + :worker_name: BulkImports::EntityWorker :feature_category: :importers :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: + :tags: + - :exclude_from_kubernetes +- :name: bulk_imports_export_request + :worker_name: BulkImports::ExportRequestWorker + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true :tags: [] - :name: bulk_imports_pipeline + :worker_name: BulkImports::PipelineWorker :feature_category: :importers :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes +- :name: bulk_imports_relation_export + :worker_name: BulkImports::RelationExportWorker + :feature_category: :importers + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: + - :exclude_from_kubernetes - :name: chat_notification + :worker_name: ChatNotificationWorker :feature_category: :chatops :has_external_dependencies: true :urgency: :low @@ -1572,14 +1893,17 @@ :idempotent: :tags: [] - :name: ci_delete_objects + :worker_name: Ci::DeleteObjectsWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: create_commit_signature + :worker_name: CreateCommitSignatureWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -1588,6 +1912,7 @@ :idempotent: true :tags: [] - :name: create_note_diff_file + :worker_name: CreateNoteDiffFileWorker :feature_category: :code_review :has_external_dependencies: :urgency: :low @@ -1596,6 +1921,7 @@ :idempotent: :tags: [] - :name: default + :worker_name: :feature_category: :has_external_dependencies: :urgency: @@ -1604,6 +1930,7 @@ :idempotent: :tags: [] - :name: delete_diff_files + :worker_name: DeleteDiffFilesWorker :feature_category: :code_review :has_external_dependencies: :urgency: :low @@ -1612,6 +1939,7 @@ :idempotent: :tags: [] - :name: delete_merged_branches + :worker_name: DeleteMergedBranchesWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -1620,6 +1948,7 @@ :idempotent: :tags: [] - :name: delete_stored_files + :worker_name: DeleteStoredFilesWorker :feature_category: :not_owned :has_external_dependencies: :urgency: :low @@ -1628,6 +1957,7 @@ :idempotent: :tags: [] - :name: delete_user + :worker_name: DeleteUserWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low @@ -1636,14 +1966,17 @@ :idempotent: :tags: [] - :name: design_management_copy_design_collection + :worker_name: DesignManagement::CopyDesignCollectionWorker :feature_category: :design_management :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: design_management_new_version + :worker_name: DesignManagement::NewVersionWorker :feature_category: :design_management :has_external_dependencies: :urgency: :low @@ -1652,14 +1985,17 @@ :idempotent: :tags: [] - :name: destroy_pages_deployments + :worker_name: DestroyPagesDeploymentsWorker :feature_category: :pages :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: detect_repository_languages + :worker_name: DetectRepositoryLanguagesWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -1668,22 +2004,27 @@ :idempotent: :tags: [] - :name: disallow_two_factor_for_group + :worker_name: DisallowTwoFactorForGroupWorker :feature_category: :subgroups :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: disallow_two_factor_for_subgroups + :worker_name: DisallowTwoFactorForSubgroupsWorker :feature_category: :subgroups :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: email_receiver + :worker_name: EmailReceiverWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :high @@ -1692,6 +2033,7 @@ :idempotent: :tags: [] - :name: emails_on_push + :worker_name: EmailsOnPushWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -1700,14 +2042,17 @@ :idempotent: :tags: [] - :name: environments_canary_ingress_update + :worker_name: Environments::CanaryIngress::UpdateWorker :feature_category: :continuous_delivery :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: error_tracking_issue_link + :worker_name: ErrorTrackingIssueLinkWorker :feature_category: :error_tracking :has_external_dependencies: true :urgency: :low @@ -1716,14 +2061,17 @@ :idempotent: :tags: [] - :name: experiments_record_conversion_event + :worker_name: Experiments::RecordConversionEventWorker :feature_category: :users :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: expire_build_instance_artifacts + :worker_name: ExpireBuildInstanceArtifactsWorker :feature_category: :continuous_integration :has_external_dependencies: :urgency: :low @@ -1732,6 +2080,7 @@ :idempotent: :tags: [] - :name: export_csv + :worker_name: ExportCsvWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low @@ -1740,6 +2089,7 @@ :idempotent: :tags: [] - :name: external_service_reactive_caching + :worker_name: ExternalServiceReactiveCachingWorker :feature_category: :not_owned :has_external_dependencies: true :urgency: :low @@ -1748,6 +2098,7 @@ :idempotent: :tags: [] - :name: file_hook + :worker_name: FileHookWorker :feature_category: :integrations :has_external_dependencies: :urgency: :low @@ -1756,22 +2107,17 @@ :idempotent: :tags: [] - :name: flush_counter_increments + :worker_name: FlushCounterIncrementsWorker :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] -- :name: git_garbage_collect - :feature_category: :gitaly - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: github_import_advance_stage + :worker_name: Gitlab::GithubImport::AdvanceStageWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -1780,14 +2126,17 @@ :idempotent: :tags: [] - :name: gitlab_performance_bar_stats + :worker_name: GitlabPerformanceBarStatsWorker :feature_category: :metrics :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: gitlab_shell + :worker_name: GitlabShellWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :high @@ -1796,6 +2145,7 @@ :idempotent: :tags: [] - :name: group_destroy + :worker_name: GroupDestroyWorker :feature_category: :subgroups :has_external_dependencies: :urgency: :low @@ -1804,7 +2154,9 @@ :idempotent: :tags: - :requires_disk_io + - :exclude_from_kubernetes - :name: group_export + :worker_name: GroupExportWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -1813,6 +2165,7 @@ :idempotent: :tags: [] - :name: group_import + :worker_name: GroupImportWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -1821,6 +2174,7 @@ :idempotent: :tags: [] - :name: import_issues_csv + :worker_name: ImportIssuesCsvWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low @@ -1829,6 +2183,7 @@ :idempotent: true :tags: [] - :name: invalid_gpg_signature_update + :worker_name: InvalidGpgSignatureUpdateWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -1837,6 +2192,7 @@ :idempotent: :tags: [] - :name: irker + :worker_name: IrkerWorker :feature_category: :integrations :has_external_dependencies: :urgency: :low @@ -1845,6 +2201,7 @@ :idempotent: :tags: [] - :name: issuable_export_csv + :worker_name: IssuableExportCsvWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low @@ -1852,7 +2209,26 @@ :weight: 1 :idempotent: :tags: [] +- :name: issuable_label_links_destroy + :worker_name: Issuable::LabelLinksDestroyWorker + :feature_category: :issue_tracking + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] +- :name: issuables_clear_groups_issue_counter + :worker_name: Issuables::ClearGroupsIssueCounterWorker + :feature_category: :issue_tracking + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: issue_placement + :worker_name: IssuePlacementWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :high @@ -1861,14 +2237,17 @@ :idempotent: true :tags: [] - :name: issue_rebalancing + :worker_name: IssueRebalancingWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: mailers + :worker_name: ActionMailer::MailDeliveryJob :feature_category: :issue_tracking :has_external_dependencies: :urgency: low @@ -1877,22 +2256,26 @@ :idempotent: :tags: [] - :name: merge + :worker_name: MergeWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: true :tags: [] - :name: merge_request_cleanup_refs + :worker_name: MergeRequestCleanupRefsWorker :feature_category: :code_review :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: merge_request_mergeability_check + :worker_name: MergeRequestMergeabilityCheckWorker :feature_category: :code_review :has_external_dependencies: :urgency: :low @@ -1901,6 +2284,7 @@ :idempotent: true :tags: [] - :name: merge_requests_assignees_change + :worker_name: MergeRequests::AssigneesChangeWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :high @@ -1909,6 +2293,7 @@ :idempotent: true :tags: [] - :name: merge_requests_delete_source_branch + :worker_name: MergeRequests::DeleteSourceBranchWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :high @@ -1917,6 +2302,7 @@ :idempotent: true :tags: [] - :name: merge_requests_handle_assignees_change + :worker_name: MergeRequests::HandleAssigneesChangeWorker :feature_category: :code_review :has_external_dependencies: :urgency: :high @@ -1925,6 +2311,7 @@ :idempotent: true :tags: [] - :name: merge_requests_resolve_todos + :worker_name: MergeRequests::ResolveTodosWorker :feature_category: :code_review :has_external_dependencies: :urgency: :high @@ -1933,6 +2320,7 @@ :idempotent: true :tags: [] - :name: metrics_dashboard_prune_old_annotations + :worker_name: Metrics::Dashboard::PruneOldAnnotationsWorker :feature_category: :metrics :has_external_dependencies: :urgency: :low @@ -1941,14 +2329,17 @@ :idempotent: true :tags: [] - :name: metrics_dashboard_sync_dashboards + :worker_name: Metrics::Dashboard::SyncDashboardsWorker :feature_category: :metrics :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: migrate_external_diffs + :worker_name: MigrateExternalDiffsWorker :feature_category: :code_review :has_external_dependencies: :urgency: :low @@ -1957,6 +2348,7 @@ :idempotent: :tags: [] - :name: namespaceless_project_destroy + :worker_name: NamespacelessProjectDestroyWorker :feature_category: :authentication_and_authorization :has_external_dependencies: :urgency: :low @@ -1965,38 +2357,47 @@ :idempotent: :tags: [] - :name: namespaces_onboarding_issue_created + :worker_name: Namespaces::OnboardingIssueCreatedWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: namespaces_onboarding_pipeline_created + :worker_name: Namespaces::OnboardingPipelineCreatedWorker :feature_category: :subgroups :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: namespaces_onboarding_progress + :worker_name: Namespaces::OnboardingProgressWorker :feature_category: :product_analytics :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: namespaces_onboarding_user_added + :worker_name: Namespaces::OnboardingUserAddedWorker :feature_category: :users :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: new_issue + :worker_name: NewIssueWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :high @@ -2005,6 +2406,7 @@ :idempotent: :tags: [] - :name: new_merge_request + :worker_name: NewMergeRequestWorker :feature_category: :code_review :has_external_dependencies: :urgency: :high @@ -2013,6 +2415,7 @@ :idempotent: :tags: [] - :name: new_note + :worker_name: NewNoteWorker :feature_category: :issue_tracking :has_external_dependencies: :urgency: :high @@ -2021,14 +2424,17 @@ :idempotent: :tags: [] - :name: packages_composer_cache_update + :worker_name: Packages::Composer::CacheUpdateWorker :feature_category: :package_registry :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: pages + :worker_name: PagesWorker :feature_category: :pages :has_external_dependencies: :urgency: :low @@ -2037,7 +2443,9 @@ :idempotent: :tags: - :requires_disk_io + - :exclude_from_kubernetes - :name: pages_domain_ssl_renewal + :worker_name: PagesDomainSslRenewalWorker :feature_category: :pages :has_external_dependencies: :urgency: :low @@ -2046,7 +2454,9 @@ :idempotent: :tags: - :requires_disk_io + - :exclude_from_kubernetes - :name: pages_domain_verification + :worker_name: PagesDomainVerificationWorker :feature_category: :pages :has_external_dependencies: :urgency: :low @@ -2055,31 +2465,39 @@ :idempotent: :tags: - :requires_disk_io + - :exclude_from_kubernetes - :name: pages_remove + :worker_name: PagesRemoveWorker :feature_category: :pages :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: pages_transfer + :worker_name: PagesTransferWorker :feature_category: :pages :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: pages_update_configuration + :worker_name: PagesUpdateConfigurationWorker :feature_category: :pages :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: phabricator_import_import_tasks + :worker_name: Gitlab::PhabricatorImport::ImportTasksWorker :feature_category: :importers :has_external_dependencies: :urgency: :low @@ -2088,6 +2506,7 @@ :idempotent: :tags: [] - :name: post_receive + :worker_name: PostReceive :feature_category: :source_code_management :has_external_dependencies: :urgency: :high @@ -2096,6 +2515,7 @@ :idempotent: :tags: [] - :name: process_commit + :worker_name: ProcessCommitWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :high @@ -2104,6 +2524,7 @@ :idempotent: true :tags: [] - :name: project_cache + :worker_name: ProjectCacheWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :high @@ -2112,6 +2533,7 @@ :idempotent: true :tags: [] - :name: project_daily_statistics + :worker_name: ProjectDailyStatisticsWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -2120,6 +2542,7 @@ :idempotent: :tags: [] - :name: project_destroy + :worker_name: ProjectDestroyWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -2128,7 +2551,9 @@ :idempotent: :tags: - :requires_disk_io + - :exclude_from_kubernetes - :name: project_export + :worker_name: ProjectExportWorker :feature_category: :importers :has_external_dependencies: :urgency: :throttled @@ -2137,6 +2562,7 @@ :idempotent: :tags: [] - :name: project_schedule_bulk_repository_shard_moves + :worker_name: ProjectScheduleBulkRepositoryShardMovesWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :throttled @@ -2145,6 +2571,7 @@ :idempotent: true :tags: [] - :name: project_service + :worker_name: ProjectServiceWorker :feature_category: :integrations :has_external_dependencies: true :urgency: :low @@ -2153,6 +2580,7 @@ :idempotent: :tags: [] - :name: project_update_repository_storage + :worker_name: ProjectUpdateRepositoryStorageWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :throttled @@ -2161,22 +2589,27 @@ :idempotent: true :tags: [] - :name: projects_git_garbage_collect + :worker_name: Projects::GitGarbageCollectWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: projects_post_creation + :worker_name: Projects::PostCreationWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: projects_schedule_bulk_repository_shard_moves + :worker_name: Projects::ScheduleBulkRepositoryShardMovesWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :throttled @@ -2185,6 +2618,7 @@ :idempotent: true :tags: [] - :name: projects_update_repository_storage + :worker_name: Projects::UpdateRepositoryStorageWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :throttled @@ -2193,6 +2627,7 @@ :idempotent: true :tags: [] - :name: prometheus_create_default_alerts + :worker_name: Prometheus::CreateDefaultAlertsWorker :feature_category: :incident_management :has_external_dependencies: :urgency: :high @@ -2201,6 +2636,7 @@ :idempotent: true :tags: [] - :name: propagate_integration + :worker_name: PropagateIntegrationWorker :feature_category: :integrations :has_external_dependencies: :urgency: :low @@ -2209,38 +2645,47 @@ :idempotent: true :tags: [] - :name: propagate_integration_group + :worker_name: PropagateIntegrationGroupWorker :feature_category: :integrations :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: propagate_integration_inherit + :worker_name: PropagateIntegrationInheritWorker :feature_category: :integrations :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: propagate_integration_inherit_descendant + :worker_name: PropagateIntegrationInheritDescendantWorker :feature_category: :integrations :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: propagate_integration_project + :worker_name: PropagateIntegrationProjectWorker :feature_category: :integrations :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: propagate_service_template + :worker_name: PropagateServiceTemplateWorker :feature_category: :integrations :has_external_dependencies: :urgency: :low @@ -2249,6 +2694,7 @@ :idempotent: :tags: [] - :name: reactive_caching + :worker_name: ReactiveCachingWorker :feature_category: :not_owned :has_external_dependencies: :urgency: :low @@ -2257,6 +2703,7 @@ :idempotent: :tags: [] - :name: rebase + :worker_name: RebaseWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -2265,14 +2712,17 @@ :idempotent: :tags: [] - :name: releases_create_evidence + :worker_name: Releases::CreateEvidenceWorker :feature_category: :release_evidence :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: remote_mirror_notification + :worker_name: RemoteMirrorNotificationWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -2281,6 +2731,7 @@ :idempotent: :tags: [] - :name: repository_cleanup + :worker_name: RepositoryCleanupWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -2289,6 +2740,7 @@ :idempotent: :tags: [] - :name: repository_fork + :worker_name: RepositoryForkWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -2297,6 +2749,7 @@ :idempotent: :tags: [] - :name: repository_import + :worker_name: RepositoryImportWorker :feature_category: :importers :has_external_dependencies: true :urgency: :low @@ -2305,6 +2758,7 @@ :idempotent: :tags: [] - :name: repository_remove_remote + :worker_name: RepositoryRemoveRemoteWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -2313,6 +2767,7 @@ :idempotent: :tags: [] - :name: repository_update_remote_mirror + :worker_name: RepositoryUpdateRemoteMirrorWorker :feature_category: :source_code_management :has_external_dependencies: true :urgency: :low @@ -2321,6 +2776,7 @@ :idempotent: true :tags: [] - :name: self_monitoring_project_create + :worker_name: SelfMonitoringProjectCreateWorker :feature_category: :metrics :has_external_dependencies: :urgency: :low @@ -2329,6 +2785,7 @@ :idempotent: :tags: [] - :name: self_monitoring_project_delete + :worker_name: SelfMonitoringProjectDeleteWorker :feature_category: :metrics :has_external_dependencies: :urgency: :low @@ -2337,7 +2794,8 @@ :idempotent: :tags: [] - :name: service_desk_email_receiver - :feature_category: :issue_tracking + :worker_name: ServiceDeskEmailReceiverWorker + :feature_category: :service_desk :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -2345,6 +2803,7 @@ :idempotent: :tags: [] - :name: snippet_schedule_bulk_repository_shard_moves + :worker_name: SnippetScheduleBulkRepositoryShardMovesWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :throttled @@ -2353,6 +2812,7 @@ :idempotent: true :tags: [] - :name: snippet_update_repository_storage + :worker_name: SnippetUpdateRepositoryStorageWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :throttled @@ -2361,6 +2821,7 @@ :idempotent: true :tags: [] - :name: snippets_schedule_bulk_repository_shard_moves + :worker_name: Snippets::ScheduleBulkRepositoryShardMovesWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :throttled @@ -2369,6 +2830,7 @@ :idempotent: true :tags: [] - :name: snippets_update_repository_storage + :worker_name: Snippets::UpdateRepositoryStorageWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :throttled @@ -2377,6 +2839,7 @@ :idempotent: true :tags: [] - :name: system_hook_push + :worker_name: SystemHookPushWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -2385,6 +2848,7 @@ :idempotent: :tags: [] - :name: update_external_pull_requests + :worker_name: UpdateExternalPullRequestsWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -2393,6 +2857,7 @@ :idempotent: :tags: [] - :name: update_highest_role + :worker_name: UpdateHighestRoleWorker :feature_category: :utilization :has_external_dependencies: :urgency: :high @@ -2401,6 +2866,7 @@ :idempotent: true :tags: [] - :name: update_merge_requests + :worker_name: UpdateMergeRequestsWorker :feature_category: :code_review :has_external_dependencies: :urgency: :high @@ -2409,6 +2875,7 @@ :idempotent: :tags: [] - :name: update_project_statistics + :worker_name: UpdateProjectStatisticsWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low @@ -2417,6 +2884,7 @@ :idempotent: :tags: [] - :name: upload_checksum + :worker_name: UploadChecksumWorker :feature_category: :geo_replication :has_external_dependencies: :urgency: :low @@ -2424,7 +2892,18 @@ :weight: 1 :idempotent: :tags: [] +- :name: users_update_open_issue_count + :worker_name: Users::UpdateOpenIssueCountWorker + :feature_category: :users + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: + - :exclude_from_kubernetes - :name: web_hook + :worker_name: WebHookWorker :feature_category: :integrations :has_external_dependencies: true :urgency: :low @@ -2433,22 +2912,27 @@ :idempotent: :tags: [] - :name: web_hooks_destroy + :worker_name: WebHooks::DestroyWorker :feature_category: :integrations :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: wikis_git_garbage_collect + :worker_name: Wikis::GitGarbageCollectWorker :feature_category: :gitaly :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: - :tags: [] + :tags: + - :exclude_from_kubernetes - :name: x509_certificate_revoke + :worker_name: X509CertificateRevokeWorker :feature_category: :source_code_management :has_external_dependencies: :urgency: :low diff --git a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb index 3ec92bc7635..083c01b166d 100644 --- a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb +++ b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb @@ -5,9 +5,12 @@ module Analytics # This worker will be removed in 14.0 class CountJobTriggerWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :devops_reports + tags :exclude_from_kubernetes urgency :low idempotent! diff --git a/app/workers/analytics/instance_statistics/counter_job_worker.rb b/app/workers/analytics/instance_statistics/counter_job_worker.rb index 4beed8a3e2f..a4dda45ff72 100644 --- a/app/workers/analytics/instance_statistics/counter_job_worker.rb +++ b/app/workers/analytics/instance_statistics/counter_job_worker.rb @@ -6,8 +6,11 @@ module Analytics class CounterJobWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :devops_reports urgency :low + tags :exclude_from_kubernetes idempotent! diff --git a/app/workers/analytics/usage_trends/count_job_trigger_worker.rb b/app/workers/analytics/usage_trends/count_job_trigger_worker.rb index 37f5c19d64c..f2d4404a964 100644 --- a/app/workers/analytics/usage_trends/count_job_trigger_worker.rb +++ b/app/workers/analytics/usage_trends/count_job_trigger_worker.rb @@ -5,11 +5,14 @@ module Analytics class CountJobTriggerWorker extend ::Gitlab::Utils::Override include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext DEFAULT_DELAY = 3.minutes.freeze feature_category :devops_reports + tags :exclude_from_kubernetes urgency :low idempotent! diff --git a/app/workers/analytics/usage_trends/counter_job_worker.rb b/app/workers/analytics/usage_trends/counter_job_worker.rb index 275c6ac2de2..f4dc497d25f 100644 --- a/app/workers/analytics/usage_trends/counter_job_worker.rb +++ b/app/workers/analytics/usage_trends/counter_job_worker.rb @@ -6,8 +6,11 @@ module Analytics extend ::Gitlab::Utils::Override include ApplicationWorker + sidekiq_options retry: 3 + feature_category :devops_reports urgency :low + tags :exclude_from_kubernetes idempotent! diff --git a/app/workers/approve_blocked_pending_approval_users_worker.rb b/app/workers/approve_blocked_pending_approval_users_worker.rb index 8ca61d68bfd..ff72aaad3ce 100644 --- a/app/workers/approve_blocked_pending_approval_users_worker.rb +++ b/app/workers/approve_blocked_pending_approval_users_worker.rb @@ -3,9 +3,12 @@ class ApproveBlockedPendingApprovalUsersWorker include ApplicationWorker + sidekiq_options retry: 3 + idempotent! feature_category :users + tags :exclude_from_kubernetes def perform(current_user_id) current_user = User.find(current_user_id) diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb index 3ddb5686bf2..629526ec17c 100644 --- a/app/workers/archive_trace_worker.rb +++ b/app/workers/archive_trace_worker.rb @@ -2,6 +2,8 @@ class ArchiveTraceWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineBackgroundQueue # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/workers/authorized_keys_worker.rb b/app/workers/authorized_keys_worker.rb index ab0e7fc4921..953f493ea2c 100644 --- a/app/workers/authorized_keys_worker.rb +++ b/app/workers/authorized_keys_worker.rb @@ -3,6 +3,8 @@ class AuthorizedKeysWorker include ApplicationWorker + sidekiq_options retry: 3 + PERMITTED_ACTIONS = %w[add_key remove_key].freeze feature_category :source_code_management diff --git a/app/workers/authorized_project_update/periodic_recalculate_worker.rb b/app/workers/authorized_project_update/periodic_recalculate_worker.rb index 78ffdbca4d6..2f6a9c42c0c 100644 --- a/app/workers/authorized_project_update/periodic_recalculate_worker.rb +++ b/app/workers/authorized_project_update/periodic_recalculate_worker.rb @@ -3,6 +3,8 @@ module AuthorizedProjectUpdate class PeriodicRecalculateWorker include ApplicationWorker + + sidekiq_options retry: 3 # This worker does not perform work scoped to a context include CronjobQueue # rubocop:disable Scalability/CronWorkerContext diff --git a/app/workers/authorized_project_update/project_create_worker.rb b/app/workers/authorized_project_update/project_create_worker.rb index 651849b57ec..52b740b4efe 100644 --- a/app/workers/authorized_project_update/project_create_worker.rb +++ b/app/workers/authorized_project_update/project_create_worker.rb @@ -4,6 +4,8 @@ module AuthorizedProjectUpdate class ProjectCreateWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :authentication_and_authorization urgency :low queue_namespace :authorized_project_update diff --git a/app/workers/authorized_project_update/project_group_link_create_worker.rb b/app/workers/authorized_project_update/project_group_link_create_worker.rb index dd24a9602bb..d887a2ce25f 100644 --- a/app/workers/authorized_project_update/project_group_link_create_worker.rb +++ b/app/workers/authorized_project_update/project_group_link_create_worker.rb @@ -4,6 +4,8 @@ module AuthorizedProjectUpdate class ProjectGroupLinkCreateWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :authentication_and_authorization urgency :low queue_namespace :authorized_project_update diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb index 6635c322ab8..2e4e2dd3232 100644 --- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb +++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb @@ -15,6 +15,8 @@ module AuthorizedProjectUpdate include ApplicationWorker + sidekiq_options retry: 3 + feature_category :authentication_and_authorization urgency :low queue_namespace :authorized_project_update @@ -22,7 +24,7 @@ module AuthorizedProjectUpdate # `data_consistency :delayed` and not `idempotent!` # See https://gitlab.com/gitlab-org/gitlab/-/issues/325291 deduplicate :until_executing, including_scheduled: true - data_consistency :delayed, feature_flag: :periodic_project_authorization_update_via_replica + data_consistency :delayed, feature_flag: :delayed_consistency_for_user_refresh_over_range_worker def perform(start_user_id, end_user_id) if Feature.enabled?(:periodic_project_authorization_update_via_replica) @@ -30,12 +32,17 @@ module AuthorizedProjectUpdate enqueue_project_authorizations_refresh(user) if project_authorizations_needs_refresh?(user) end else + use_primary_database AuthorizedProjectUpdate::RecalculateForUserRangeService.new(start_user_id, end_user_id).execute end end private + def use_primary_database + # no-op in CE, overriden in EE + end + def project_authorizations_needs_refresh?(user) AuthorizedProjectUpdate::FindRecordsDueForRefreshService.new(user).needs_refresh? end @@ -47,3 +54,5 @@ module AuthorizedProjectUpdate end end end + +AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker.prepend_mod_with('AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker') diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index 6e07d6d0f71..a1068117e59 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -2,6 +2,8 @@ class AuthorizedProjectsWorker include ApplicationWorker + + sidekiq_options retry: 3 prepend WaitableWorker feature_category :authentication_and_authorization diff --git a/app/workers/auto_devops/disable_worker.rb b/app/workers/auto_devops/disable_worker.rb index bae08cf9e18..43377382e82 100644 --- a/app/workers/auto_devops/disable_worker.rb +++ b/app/workers/auto_devops/disable_worker.rb @@ -3,6 +3,8 @@ module AutoDevops class DisableWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include AutoDevopsQueue def perform(pipeline_id) diff --git a/app/workers/auto_merge_process_worker.rb b/app/workers/auto_merge_process_worker.rb index 2599c76c900..dda0e970834 100644 --- a/app/workers/auto_merge_process_worker.rb +++ b/app/workers/auto_merge_process_worker.rb @@ -3,6 +3,8 @@ class AutoMergeProcessWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :auto_merge feature_category :continuous_delivery worker_resource_boundary :cpu diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb index 70c4ad53726..6b1f10f75b8 100644 --- a/app/workers/background_migration_worker.rb +++ b/app/workers/background_migration_worker.rb @@ -3,6 +3,8 @@ class BackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :database urgency :throttled loggable_arguments 0, 1 diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index aeda8d113ac..a3eaacec8a2 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -2,6 +2,8 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_processing @@ -34,7 +36,6 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker # We execute these async as these are independent operations. BuildHooksWorker.perform_async(build.id) - ExpirePipelineCacheWorker.perform_async(build.pipeline_id) ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat? if build.failed? @@ -57,4 +58,4 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker end end -BuildFinishedWorker.prepend_if_ee('EE::BuildFinishedWorker') +BuildFinishedWorker.prepend_mod_with('BuildFinishedWorker') diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb index 5e05063f058..be79d6b2afb 100644 --- a/app/workers/build_hooks_worker.rb +++ b/app/workers/build_hooks_worker.rb @@ -2,6 +2,8 @@ class BuildHooksWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_hooks @@ -9,6 +11,16 @@ class BuildHooksWorker # rubocop:disable Scalability/IdempotentWorker urgency :high data_consistency :delayed, feature_flag: :load_balancing_for_build_hooks_worker + DATA_CONSISTENCY_DELAY = 3 + + def self.perform_async(*args) + if Feature.enabled?(:delayed_perform_for_build_hooks_worker, default_enabled: :yaml) + perform_in(DATA_CONSISTENCY_DELAY.seconds, *args) + else + super + end + end + # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) Ci::Build.includes({ runner: :tags }) diff --git a/app/workers/build_queue_worker.rb b/app/workers/build_queue_worker.rb index b71afbbeb8f..e9bb2d88a81 100644 --- a/app/workers/build_queue_worker.rb +++ b/app/workers/build_queue_worker.rb @@ -2,6 +2,8 @@ class BuildQueueWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_processing diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index e4a2dd500cc..531e7e5a5fe 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -2,6 +2,8 @@ class BuildSuccessWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_processing diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index b4b9d9b05c1..8ad31c68374 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -4,6 +4,7 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker feature_category :importers + tags :exclude_from_kubernetes sidekiq_options retry: false, dead: false @@ -23,13 +24,14 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker created_entities.first(next_batch_size).each do |entity| create_pipeline_tracker_for(entity) + BulkImports::ExportRequestWorker.perform_async(entity.id) BulkImports::EntityWorker.perform_async(entity.id) entity.start! end re_enqueue - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, bulk_import_id: @bulk_import&.id) @bulk_import&.fail_op diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb index 7f173b738cf..e7fce112ee1 100644 --- a/app/workers/bulk_imports/entity_worker.rb +++ b/app/workers/bulk_imports/entity_worker.rb @@ -5,6 +5,7 @@ module BulkImports include ApplicationWorker feature_category :importers + tags :exclude_from_kubernetes sidekiq_options retry: false, dead: false @@ -26,7 +27,7 @@ module BulkImports entity_id ) end - rescue => e + rescue StandardError => e logger.error( worker: self.class.name, entity_id: entity_id, diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb new file mode 100644 index 00000000000..cccc24d3bdc --- /dev/null +++ b/app/workers/bulk_imports/export_request_worker.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module BulkImports + class ExportRequestWorker + include ApplicationWorker + + idempotent! + worker_has_external_dependencies! + feature_category :importers + + GROUP_EXPORTED_URL_PATH = "/groups/%s/export_relations" + + def perform(entity_id) + entity = BulkImports::Entity.find(entity_id) + + request_export(entity) + end + + private + + def request_export(entity) + http_client(entity.bulk_import.configuration) + .post(GROUP_EXPORTED_URL_PATH % entity.encoded_source_full_path) + end + + def http_client(configuration) + @client ||= Clients::Http.new( + uri: configuration.url, + token: configuration.access_token + ) + end + end +end diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index a6de3c36205..256301bf097 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -5,6 +5,7 @@ module BulkImports include ApplicationWorker feature_category :importers + tags :exclude_from_kubernetes sidekiq_options retry: false, dead: false @@ -46,7 +47,7 @@ module BulkImports pipeline_tracker.pipeline_class.new(context).run pipeline_tracker.finish! - rescue => e + rescue StandardError => e pipeline_tracker.fail_op! logger.error( diff --git a/app/workers/bulk_imports/relation_export_worker.rb b/app/workers/bulk_imports/relation_export_worker.rb new file mode 100644 index 00000000000..9d9449e3a1b --- /dev/null +++ b/app/workers/bulk_imports/relation_export_worker.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module BulkImports + class RelationExportWorker + include ApplicationWorker + include ExceptionBacktrace + + idempotent! + loggable_arguments 2, 3 + feature_category :importers + tags :exclude_from_kubernetes + sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION + + def perform(user_id, portable_id, portable_class, relation) + user = User.find(user_id) + portable = portable(portable_id, portable_class) + + RelationExportService.new(user, portable, relation, jid).execute + end + + private + + def portable(portable_id, portable_class) + portable_class.classify.constantize.find(portable_id) + end + end +end diff --git a/app/workers/chaos/cpu_spin_worker.rb b/app/workers/chaos/cpu_spin_worker.rb index 0b565e0d49c..f8900abc764 100644 --- a/app/workers/chaos/cpu_spin_worker.rb +++ b/app/workers/chaos/cpu_spin_worker.rb @@ -3,6 +3,8 @@ module Chaos class CpuSpinWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ChaosQueue def perform(duration_s) diff --git a/app/workers/chaos/db_spin_worker.rb b/app/workers/chaos/db_spin_worker.rb index 099660d440c..9b5d06414a9 100644 --- a/app/workers/chaos/db_spin_worker.rb +++ b/app/workers/chaos/db_spin_worker.rb @@ -3,6 +3,8 @@ module Chaos class DbSpinWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ChaosQueue def perform(duration_s, interval_s) diff --git a/app/workers/chaos/leak_mem_worker.rb b/app/workers/chaos/leak_mem_worker.rb index b77d1a20541..788009962db 100644 --- a/app/workers/chaos/leak_mem_worker.rb +++ b/app/workers/chaos/leak_mem_worker.rb @@ -3,6 +3,8 @@ module Chaos class LeakMemWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ChaosQueue def perform(memory_mb, duration_s) diff --git a/app/workers/chaos/sleep_worker.rb b/app/workers/chaos/sleep_worker.rb index 6887258e961..b9ff5546384 100644 --- a/app/workers/chaos/sleep_worker.rb +++ b/app/workers/chaos/sleep_worker.rb @@ -3,6 +3,8 @@ module Chaos class SleepWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ChaosQueue def perform(duration_s) diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb index 0171c1d482d..c748bc33ada 100644 --- a/app/workers/ci/archive_traces_cron_worker.rb +++ b/app/workers/ci/archive_traces_cron_worker.rb @@ -3,6 +3,8 @@ module Ci class ArchiveTracesCronWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :continuous_integration diff --git a/app/workers/ci/build_prepare_worker.rb b/app/workers/ci/build_prepare_worker.rb index 7f640633070..f30e9d3b885 100644 --- a/app/workers/ci/build_prepare_worker.rb +++ b/app/workers/ci/build_prepare_worker.rb @@ -3,6 +3,8 @@ module Ci class BuildPrepareWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_processing diff --git a/app/workers/ci/build_schedule_worker.rb b/app/workers/ci/build_schedule_worker.rb index 9231b40978d..570f5f28c3d 100644 --- a/app/workers/ci/build_schedule_worker.rb +++ b/app/workers/ci/build_schedule_worker.rb @@ -3,6 +3,8 @@ module Ci class BuildScheduleWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_processing diff --git a/app/workers/ci/build_trace_chunk_flush_worker.rb b/app/workers/ci/build_trace_chunk_flush_worker.rb index a63b12c0d03..1e0da73e08d 100644 --- a/app/workers/ci/build_trace_chunk_flush_worker.rb +++ b/app/workers/ci/build_trace_chunk_flush_worker.rb @@ -3,6 +3,8 @@ module Ci class BuildTraceChunkFlushWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineBackgroundQueue deduplicate :until_executed diff --git a/app/workers/ci/create_cross_project_pipeline_worker.rb b/app/workers/ci/create_cross_project_pipeline_worker.rb index 679574d9f60..4881ee12e5c 100644 --- a/app/workers/ci/create_cross_project_pipeline_worker.rb +++ b/app/workers/ci/create_cross_project_pipeline_worker.rb @@ -5,6 +5,7 @@ module Ci include ::ApplicationWorker include ::PipelineQueue + sidekiq_options retry: 3 worker_resource_boundary :cpu def perform(bridge_id) diff --git a/app/workers/ci/daily_build_group_report_results_worker.rb b/app/workers/ci/daily_build_group_report_results_worker.rb index 687cadc6366..b38bef3bcf8 100644 --- a/app/workers/ci/daily_build_group_report_results_worker.rb +++ b/app/workers/ci/daily_build_group_report_results_worker.rb @@ -3,6 +3,8 @@ module Ci class DailyBuildGroupReportResultsWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineBackgroundQueue feature_category :code_testing diff --git a/app/workers/ci/delete_objects_worker.rb b/app/workers/ci/delete_objects_worker.rb index d845ad61358..ff020a3b048 100644 --- a/app/workers/ci/delete_objects_worker.rb +++ b/app/workers/ci/delete_objects_worker.rb @@ -3,9 +3,12 @@ module Ci class DeleteObjectsWorker include ApplicationWorker + + sidekiq_options retry: 3 include LimitedCapacity::Worker feature_category :continuous_integration + tags :exclude_from_kubernetes idempotent! def perform_work(*args) diff --git a/app/workers/ci/delete_unit_tests_worker.rb b/app/workers/ci/delete_unit_tests_worker.rb new file mode 100644 index 00000000000..ddfc70c43d4 --- /dev/null +++ b/app/workers/ci/delete_unit_tests_worker.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ci + class DeleteUnitTestsWorker + include ApplicationWorker + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + feature_category :continuous_integration + idempotent! + + def perform + Ci::DeleteUnitTestsService.new.execute + end + end +end diff --git a/app/workers/ci/drop_pipeline_worker.rb b/app/workers/ci/drop_pipeline_worker.rb index d19157a47e8..bc158433228 100644 --- a/app/workers/ci/drop_pipeline_worker.rb +++ b/app/workers/ci/drop_pipeline_worker.rb @@ -3,8 +3,12 @@ module Ci class DropPipelineWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue + tags :exclude_from_kubernetes + idempotent! def perform(pipeline_id, failure_reason) diff --git a/app/workers/ci/initial_pipeline_process_worker.rb b/app/workers/ci/initial_pipeline_process_worker.rb index f59726c87fb..4dace43298d 100644 --- a/app/workers/ci/initial_pipeline_process_worker.rb +++ b/app/workers/ci/initial_pipeline_process_worker.rb @@ -3,6 +3,8 @@ module Ci class InitialPipelineProcessWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_processing diff --git a/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb b/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb index d5e097dc2b5..bd061b5f988 100644 --- a/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb +++ b/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb @@ -3,9 +3,12 @@ module Ci module MergeRequests class AddTodoWhenBuildFailsWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue urgency :low + tags :exclude_from_kubernetes idempotent! def perform(job_id) @@ -14,7 +17,7 @@ module Ci return unless job && project - ::MergeRequests::AddTodoWhenBuildFailsService.new(job.project, nil).execute(job) + ::MergeRequests::AddTodoWhenBuildFailsService.new(project: job.project).execute(job) end end end diff --git a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb index 4de56f54f44..dd7bfff4eb1 100644 --- a/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb +++ b/app/workers/ci/pipeline_artifacts/coverage_report_worker.rb @@ -4,9 +4,12 @@ module Ci module PipelineArtifacts class CoverageReportWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineBackgroundQueue feature_category :code_testing + tags :exclude_from_kubernetes idempotent! diff --git a/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb b/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb index 810106e8d9c..558153c69b2 100644 --- a/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb +++ b/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb @@ -5,14 +5,17 @@ module Ci class CreateQualityReportWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :pipeline_background feature_category :code_testing + tags :exclude_from_kubernetes idempotent! def perform(pipeline_id) Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| - Ci::PipelineArtifacts::CreateCodeQualityMrDiffReportService.new.execute(pipeline) + Ci::PipelineArtifacts::CreateCodeQualityMrDiffReportService.new(pipeline).execute end end end diff --git a/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb b/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb index fff979d95a9..004c1d444a2 100644 --- a/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb +++ b/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb @@ -4,6 +4,8 @@ module Ci module PipelineArtifacts class ExpireArtifactsWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue @@ -12,6 +14,7 @@ module Ci deduplicate :until_executed, including_scheduled: true idempotent! feature_category :continuous_integration + tags :exclude_from_kubernetes def perform service = ::Ci::PipelineArtifacts::DestroyAllExpiredService.new diff --git a/app/workers/ci/pipeline_bridge_status_worker.rb b/app/workers/ci/pipeline_bridge_status_worker.rb index 3f92f4561e0..3630331b41d 100644 --- a/app/workers/ci/pipeline_bridge_status_worker.rb +++ b/app/workers/ci/pipeline_bridge_status_worker.rb @@ -5,6 +5,7 @@ module Ci include ::ApplicationWorker include ::PipelineQueue + sidekiq_options retry: 3 urgency :high worker_resource_boundary :cpu diff --git a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb index bc31876aa1d..b0921f6e10b 100644 --- a/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb +++ b/app/workers/ci/pipeline_success_unlock_artifacts_worker.rb @@ -3,6 +3,8 @@ module Ci class PipelineSuccessUnlockArtifactsWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineBackgroundQueue idempotent! diff --git a/app/workers/ci/ref_delete_unlock_artifacts_worker.rb b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb index aaa77efbb74..d20c501100e 100644 --- a/app/workers/ci/ref_delete_unlock_artifacts_worker.rb +++ b/app/workers/ci/ref_delete_unlock_artifacts_worker.rb @@ -3,6 +3,8 @@ module Ci class RefDeleteUnlockArtifactsWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineBackgroundQueue idempotent! diff --git a/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb index 8063e34a1b8..15ed89fd00e 100644 --- a/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb +++ b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb @@ -4,6 +4,8 @@ module Ci module ResourceGroups class AssignResourceFromResourceGroupWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_processing diff --git a/app/workers/ci/retry_pipeline_worker.rb b/app/workers/ci/retry_pipeline_worker.rb new file mode 100644 index 00000000000..7a1906b3ef9 --- /dev/null +++ b/app/workers/ci/retry_pipeline_worker.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Ci + class RetryPipelineWorker # rubocop:disable Scalability/IdempotentWorker + include ::ApplicationWorker + include ::PipelineQueue + + urgency :high + worker_resource_boundary :cpu + + def perform(pipeline_id, user_id) + ::Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| + ::User.find_by_id(user_id).try do |user| + pipeline.retry_failed(user) + end + end + end + end +end diff --git a/app/workers/ci/schedule_delete_objects_cron_worker.rb b/app/workers/ci/schedule_delete_objects_cron_worker.rb index fa0b15deb56..6489665fafd 100644 --- a/app/workers/ci/schedule_delete_objects_cron_worker.rb +++ b/app/workers/ci/schedule_delete_objects_cron_worker.rb @@ -3,12 +3,15 @@ module Ci class ScheduleDeleteObjectsCronWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue # rubocop:enable Scalability/CronWorkerContext feature_category :continuous_integration + tags :exclude_from_kubernetes idempotent! def perform(*args) diff --git a/app/workers/ci/test_failure_history_worker.rb b/app/workers/ci/test_failure_history_worker.rb index e1562cb3836..3937f720788 100644 --- a/app/workers/ci/test_failure_history_worker.rb +++ b/app/workers/ci/test_failure_history_worker.rb @@ -3,8 +3,12 @@ module Ci class TestFailureHistoryWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineBackgroundQueue + tags :exclude_from_kubernetes + idempotent! def perform(pipeline_id) diff --git a/app/workers/ci_platform_metrics_update_cron_worker.rb b/app/workers/ci_platform_metrics_update_cron_worker.rb index ec1fc26fad3..05af0a0a73b 100644 --- a/app/workers/ci_platform_metrics_update_cron_worker.rb +++ b/app/workers/ci_platform_metrics_update_cron_worker.rb @@ -3,6 +3,8 @@ class CiPlatformMetricsUpdateCronWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + # This worker does not perform work scoped to a context include CronjobQueue # rubocop:disable Scalability/CronWorkerContext diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb index 1cac2858156..a8de8efbce6 100644 --- a/app/workers/cleanup_container_repository_worker.rb +++ b/app/workers/cleanup_container_repository_worker.rb @@ -3,6 +3,8 @@ class CleanupContainerRepositoryWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :container_repository feature_category :container_registry urgency :low diff --git a/app/workers/cluster_configure_istio_worker.rb b/app/workers/cluster_configure_istio_worker.rb index ec6bdfbd6b6..07c032da838 100644 --- a/app/workers/cluster_configure_istio_worker.rb +++ b/app/workers/cluster_configure_istio_worker.rb @@ -2,6 +2,8 @@ class ClusterConfigureIstioWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue worker_has_external_dependencies! diff --git a/app/workers/cluster_install_app_worker.rb b/app/workers/cluster_install_app_worker.rb index f3da4d5c4bb..71374de19f5 100644 --- a/app/workers/cluster_install_app_worker.rb +++ b/app/workers/cluster_install_app_worker.rb @@ -2,6 +2,8 @@ class ClusterInstallAppWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue include ClusterApplications diff --git a/app/workers/cluster_patch_app_worker.rb b/app/workers/cluster_patch_app_worker.rb index b0393809802..674a565f7f7 100644 --- a/app/workers/cluster_patch_app_worker.rb +++ b/app/workers/cluster_patch_app_worker.rb @@ -2,6 +2,8 @@ class ClusterPatchAppWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue include ClusterApplications diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb index cb750f3021e..142ad84f746 100644 --- a/app/workers/cluster_provision_worker.rb +++ b/app/workers/cluster_provision_worker.rb @@ -2,6 +2,8 @@ class ClusterProvisionWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue worker_has_external_dependencies! diff --git a/app/workers/cluster_update_app_worker.rb b/app/workers/cluster_update_app_worker.rb index 29feb813043..dc57a1a90d9 100644 --- a/app/workers/cluster_update_app_worker.rb +++ b/app/workers/cluster_update_app_worker.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Deprecated, to be removed in %14.0 as part of https://gitlab.com/groups/gitlab-org/-/epics/4280 class ClusterUpdateAppWorker # rubocop:disable Scalability/IdempotentWorker UpdateAlreadyInProgressError = Class.new(StandardError) @@ -35,6 +36,7 @@ class ClusterUpdateAppWorker # rubocop:disable Scalability/IdempotentWorker # rubocop: enable CodeReuse/ActiveRecord def update_prometheus(app, scheduled_time, project) + return unless app.managed_prometheus? return if app.updated_since?(scheduled_time) return if app.update_in_progress? diff --git a/app/workers/cluster_upgrade_app_worker.rb b/app/workers/cluster_upgrade_app_worker.rb index d4650ab3a85..909ada2044f 100644 --- a/app/workers/cluster_upgrade_app_worker.rb +++ b/app/workers/cluster_upgrade_app_worker.rb @@ -2,6 +2,8 @@ class ClusterUpgradeAppWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue include ClusterApplications diff --git a/app/workers/cluster_wait_for_app_installation_worker.rb b/app/workers/cluster_wait_for_app_installation_worker.rb index 4bc29807ea4..19e33cd17b0 100644 --- a/app/workers/cluster_wait_for_app_installation_worker.rb +++ b/app/workers/cluster_wait_for_app_installation_worker.rb @@ -2,6 +2,8 @@ class ClusterWaitForAppInstallationWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue include ClusterApplications diff --git a/app/workers/cluster_wait_for_app_update_worker.rb b/app/workers/cluster_wait_for_app_update_worker.rb index c0a11eb93a7..185959884a1 100644 --- a/app/workers/cluster_wait_for_app_update_worker.rb +++ b/app/workers/cluster_wait_for_app_update_worker.rb @@ -2,6 +2,8 @@ class ClusterWaitForAppUpdateWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue include ClusterApplications diff --git a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb index fa46135d279..4a010c749a2 100644 --- a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb +++ b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb @@ -2,6 +2,8 @@ class ClusterWaitForIngressIpAddressWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue include ClusterApplications diff --git a/app/workers/clusters/applications/activate_service_worker.rb b/app/workers/clusters/applications/activate_service_worker.rb index c92f978a7d2..d4d0ae96e03 100644 --- a/app/workers/clusters/applications/activate_service_worker.rb +++ b/app/workers/clusters/applications/activate_service_worker.rb @@ -4,6 +4,8 @@ module Clusters module Applications class ActivateServiceWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue loggable_arguments 1 diff --git a/app/workers/clusters/applications/check_prometheus_health_worker.rb b/app/workers/clusters/applications/check_prometheus_health_worker.rb index cf9534c9a78..4db7314cbc0 100644 --- a/app/workers/clusters/applications/check_prometheus_health_worker.rb +++ b/app/workers/clusters/applications/check_prometheus_health_worker.rb @@ -4,6 +4,8 @@ module Clusters module Applications class CheckPrometheusHealthWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/clusters/applications/deactivate_service_worker.rb b/app/workers/clusters/applications/deactivate_service_worker.rb index 4d103bb0edc..935b455a4fc 100644 --- a/app/workers/clusters/applications/deactivate_service_worker.rb +++ b/app/workers/clusters/applications/deactivate_service_worker.rb @@ -4,6 +4,8 @@ module Clusters module Applications class DeactivateServiceWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue loggable_arguments 1 diff --git a/app/workers/clusters/applications/uninstall_worker.rb b/app/workers/clusters/applications/uninstall_worker.rb index a9307931b59..3a4564ca7ab 100644 --- a/app/workers/clusters/applications/uninstall_worker.rb +++ b/app/workers/clusters/applications/uninstall_worker.rb @@ -4,6 +4,8 @@ module Clusters module Applications class UninstallWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue include ClusterApplications diff --git a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb index dc842788374..18801ad7e64 100644 --- a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb +++ b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb @@ -4,6 +4,8 @@ module Clusters module Applications class WaitForUninstallAppWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue include ClusterApplications diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index 0de26e27631..843be4896a3 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -16,6 +16,7 @@ module ApplicationWorker included do set_queue + after_set_class_attribute { set_queue } def structured_payload(payload = {}) context = Gitlab::ApplicationContext.current.merge( @@ -47,22 +48,14 @@ module ApplicationWorker class_methods do def inherited(subclass) subclass.set_queue + subclass.after_set_class_attribute { subclass.set_queue } end def set_queue - queue_name = [queue_namespace, base_queue_name].compact.join(':') - + queue_name = ::Gitlab::SidekiqConfig::WorkerRouter.global.route(self) sidekiq_options queue: queue_name # rubocop:disable Cop/SidekiqOptionsQueue end - def base_queue_name - name - .sub(/\AGitlab::/, '') - .sub(/Worker\z/, '') - .underscore - .tr('/', '_') - end - def queue_namespace(new_namespace = nil) if new_namespace sidekiq_options queue_namespace: new_namespace diff --git a/app/workers/concerns/chaos_queue.rb b/app/workers/concerns/chaos_queue.rb index a9c557f0175..2ccd55157c6 100644 --- a/app/workers/concerns/chaos_queue.rb +++ b/app/workers/concerns/chaos_queue.rb @@ -6,5 +6,6 @@ module ChaosQueue included do queue_namespace :chaos feature_category_not_owned! + tags :exclude_from_gitlab_com end end diff --git a/app/workers/concerns/git_garbage_collect_methods.rb b/app/workers/concerns/git_garbage_collect_methods.rb index 17a80d1ddb3..c46deeb716f 100644 --- a/app/workers/concerns/git_garbage_collect_methods.rb +++ b/app/workers/concerns/git_garbage_collect_methods.rb @@ -97,10 +97,10 @@ module GitGarbageCollectMethods end rescue GRPC::NotFound => e Gitlab::GitLogger.error("#{__method__} failed:\nRepository not found") - raise Gitlab::Git::Repository::NoRepository.new(e) + raise Gitlab::Git::Repository::NoRepository, e rescue GRPC::BadStatus => e Gitlab::GitLogger.error("#{__method__} failed:\n#{e}") - raise Gitlab::Git::CommandError.new(e) + raise Gitlab::Git::CommandError, e end def get_gitaly_client(task, repository) diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index 575cd4862b0..6ebf7c7c263 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -9,6 +9,8 @@ module Gitlab included do include ApplicationWorker + + sidekiq_options retry: 3 include GithubImport::Queue include ReschedulingMethods include Gitlab::NotifyUponDeath @@ -25,15 +27,19 @@ module Gitlab # client - An instance of `Gitlab::GithubImport::Client` # hash - A Hash containing the details of the object to import. def import(project, client, hash) + object = representation_class.from_json_hash(hash) + + # To better express in the logs what object is being imported. + self.github_id = object.attributes.fetch(:github_id) + info(project.id, message: 'starting importer') - object = representation_class.from_json_hash(hash) importer_class.new(object, project, client).execute counter.increment info(project.id, message: 'importer finished') - rescue => e - error(project.id, e) + rescue StandardError => e + error(project.id, e, hash) end def counter @@ -63,16 +69,19 @@ module Gitlab private + attr_accessor :github_id + def info(project_id, extra = {}) logger.info(log_attributes(project_id, extra)) end - def error(project_id, exception) + def error(project_id, exception, data = {}) logger.error( log_attributes( project_id, message: 'importer failed', - 'error.message': exception.message + 'error.message': exception.message, + 'github.data': data ) ) @@ -86,7 +95,8 @@ module Gitlab extra.merge( import_source: :github, project_id: project_id, - importer: importer_class.name + importer: importer_class.name, + github_id: github_id ) end end diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index e5985fb94da..916b273a28f 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -14,7 +14,7 @@ module Gitlab try_import(client, project) info(project_id, message: 'stage finished') - rescue => e + rescue StandardError => e error(project_id, e) end diff --git a/app/workers/concerns/gitlab/jira_import/import_worker.rb b/app/workers/concerns/gitlab/jira_import/import_worker.rb index fdc6e64bbaa..107b6e2e9be 100644 --- a/app/workers/concerns/gitlab/jira_import/import_worker.rb +++ b/app/workers/concerns/gitlab/jira_import/import_worker.rb @@ -7,6 +7,8 @@ module Gitlab included do include ApplicationWorker + + sidekiq_options retry: 3 include ProjectImportOptions include Gitlab::JiraImport::QueueOptions end diff --git a/app/workers/concerns/limited_capacity/job_tracker.rb b/app/workers/concerns/limited_capacity/job_tracker.rb index 96b6e1a2024..47b13cd5bf6 100644 --- a/app/workers/concerns/limited_capacity/job_tracker.rb +++ b/app/workers/concerns/limited_capacity/job_tracker.rb @@ -3,21 +3,30 @@ module LimitedCapacity class JobTracker # rubocop:disable Scalability/IdempotentWorker include Gitlab::Utils::StrongMemoize + LUA_REGISTER_SCRIPT = <<~EOS + local set_key, element, max_elements = KEYS[1], ARGV[1], ARGV[2] + + if redis.call("scard", set_key) < tonumber(max_elements) then + redis.call("sadd", set_key, element) + return true + end + + return false + EOS + def initialize(namespace) @namespace = namespace end - def register(jid) - _added, @count = with_redis_pipeline do |redis| - register_job_keys(redis, jid) - get_job_count(redis) - end + def register(jid, max_jids) + with_redis do |redis| + redis.eval(LUA_REGISTER_SCRIPT, keys: [counter_key], argv: [jid, max_jids]) + end.present? end def remove(jid) - _removed, @count = with_redis_pipeline do |redis| + with_redis do |redis| remove_job_keys(redis, jid) - get_job_count(redis) end end @@ -25,14 +34,13 @@ module LimitedCapacity completed_jids = Gitlab::SidekiqStatus.completed_jids(running_jids) return unless completed_jids.any? - _removed, @count = with_redis_pipeline do |redis| + with_redis do |redis| remove_job_keys(redis, completed_jids) - get_job_count(redis) end end def count - @count ||= with_redis { |redis| get_job_count(redis) } + with_redis { |redis| redis.scard(counter_key) } end def running_jids @@ -49,14 +57,6 @@ module LimitedCapacity "worker:#{namespace.to_s.underscore}:running" end - def get_job_count(redis) - redis.scard(counter_key) - end - - def register_job_keys(redis, keys) - redis.sadd(counter_key, keys) - end - def remove_job_keys(redis, keys) redis.srem(counter_key, keys) end @@ -64,11 +64,5 @@ module LimitedCapacity def with_redis(&block) Gitlab::Redis::Queues.with(&block) # rubocop: disable CodeReuse/ActiveRecord end - - def with_redis_pipeline(&block) - with_redis do |redis| - redis.pipelined(&block) - end - end end end diff --git a/app/workers/concerns/limited_capacity/worker.rb b/app/workers/concerns/limited_capacity/worker.rb index 9dd8d942146..b4cdfda680f 100644 --- a/app/workers/concerns/limited_capacity/worker.rb +++ b/app/workers/concerns/limited_capacity/worker.rb @@ -55,26 +55,14 @@ module LimitedCapacity def perform_with_capacity(*args) worker = self.new worker.remove_failed_jobs - worker.report_prometheus_metrics(*args) - required_jobs_count = worker.required_jobs_count(*args) - arguments = Array.new(required_jobs_count) { args } + arguments = Array.new(worker.max_running_jobs) { args } self.bulk_perform_async(arguments) # rubocop:disable Scalability/BulkPerformWithContext end end def perform(*args) - return unless has_capacity? - - job_tracker.register(jid) - report_running_jobs_metrics - perform_work(*args) - rescue => exception - raise - ensure - job_tracker.remove(jid) - report_prometheus_metrics(*args) - re_enqueue(*args) unless exception + perform_registered(*args) if job_tracker.register(jid, max_running_jobs) end def perform_work(*args) @@ -89,43 +77,32 @@ module LimitedCapacity raise NotImplementedError end - def has_capacity? - remaining_capacity > 0 - end - - def remaining_capacity - [ - max_running_jobs - running_jobs_count - self.class.queue_size, - 0 - ].max - end - - def has_work?(*args) - remaining_work_count(*args) > 0 - end - def remove_failed_jobs job_tracker.clean_up end def report_prometheus_metrics(*args) report_running_jobs_metrics - remaining_work_gauge.set(prometheus_labels, remaining_work_count(*args)) - max_running_jobs_gauge.set(prometheus_labels, max_running_jobs) + set_metric(:remaining_work_gauge, remaining_work_count(*args)) + set_metric(:max_running_jobs_gauge, max_running_jobs) end - def report_running_jobs_metrics - running_jobs_gauge.set(prometheus_labels, running_jobs_count) - end + private - def required_jobs_count(*args) - [ - remaining_work_count(*args), - remaining_capacity - ].min + def perform_registered(*args) + report_running_jobs_metrics + perform_work(*args) + rescue StandardError => exception + raise + ensure + job_tracker.remove(jid) + report_prometheus_metrics(*args) + re_enqueue(*args) unless exception end - private + def report_running_jobs_metrics + set_metric(:running_jobs_gauge, running_jobs_count) + end def running_jobs_count job_tracker.count @@ -138,32 +115,21 @@ module LimitedCapacity end def re_enqueue(*args) - return unless has_capacity? - return unless has_work?(*args) + return unless remaining_work_count(*args) > 0 self.class.perform_async(*args) end - def running_jobs_gauge - strong_memoize(:running_jobs_gauge) do - Gitlab::Metrics.gauge(:limited_capacity_worker_running_jobs, 'Number of running jobs') - end - end - - def max_running_jobs_gauge - strong_memoize(:max_running_jobs_gauge) do - Gitlab::Metrics.gauge(:limited_capacity_worker_max_running_jobs, 'Maximum number of running jobs') + def set_metric(name, value) + metrics = strong_memoize(:metrics) do + { + running_jobs_gauge: Gitlab::Metrics.gauge(:limited_capacity_worker_running_jobs, 'Number of running jobs'), + max_running_jobs_gauge: Gitlab::Metrics.gauge(:limited_capacity_worker_max_running_jobs, 'Maximum number of running jobs'), + remaining_work_gauge: Gitlab::Metrics.gauge(:limited_capacity_worker_remaining_work_count, 'Number of jobs waiting to be enqueued') + } end - end - - def remaining_work_gauge - strong_memoize(:remaining_work_gauge) do - Gitlab::Metrics.gauge(:limited_capacity_worker_remaining_work_count, 'Number of jobs waiting to be enqueued') - end - end - def prometheus_labels - { worker: self.class.name } + metrics[name].set({ worker: self.class.name }, value) end end end diff --git a/app/workers/concerns/reactive_cacheable_worker.rb b/app/workers/concerns/reactive_cacheable_worker.rb index 9e882c8ac7a..78fcf8087c2 100644 --- a/app/workers/concerns/reactive_cacheable_worker.rb +++ b/app/workers/concerns/reactive_cacheable_worker.rb @@ -6,6 +6,8 @@ module ReactiveCacheableWorker included do include ApplicationWorker + sidekiq_options retry: 3 + feature_category_not_owned! loggable_arguments 0 diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb index c3abcdafcf2..e62bd8d9885 100644 --- a/app/workers/concerns/waitable_worker.rb +++ b/app/workers/concerns/waitable_worker.rb @@ -33,7 +33,7 @@ module WaitableWorker args_list.each do |args| new.perform(*args) - rescue + rescue StandardError failed << args end diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb index 6f99fd089ac..6dee9402691 100644 --- a/app/workers/concerns/worker_attributes.rb +++ b/app/workers/concerns/worker_attributes.rb @@ -36,13 +36,13 @@ module WorkerAttributes def feature_category(value, *extras) raise "Invalid category. Use `feature_category_not_owned!` to mark a worker as not owned" if value == :not_owned - class_attributes[:feature_category] = value + set_class_attribute(:feature_category, value) end # Special case: mark this work as not associated with a feature category # this should be used for cross-cutting concerns, such as mailer workers. def feature_category_not_owned! - class_attributes[:feature_category] = :not_owned + set_class_attribute(:feature_category, :not_owned) end def get_feature_category @@ -64,7 +64,7 @@ module WorkerAttributes def urgency(urgency) raise "Invalid urgency: #{urgency}" unless VALID_URGENCIES.include?(urgency) - class_attributes[:urgency] = urgency + set_class_attribute(:urgency, urgency) end def get_urgency @@ -75,8 +75,8 @@ module WorkerAttributes raise ArgumentError, "Invalid data consistency: #{data_consistency}" unless VALID_DATA_CONSISTENCIES.include?(data_consistency) raise ArgumentError, 'Data consistency is already set' if class_attributes[:data_consistency] - class_attributes[:data_consistency_feature_flag] = feature_flag if feature_flag - class_attributes[:data_consistency] = data_consistency + set_class_attribute(:data_consistency_feature_flag, feature_flag) if feature_flag + set_class_attribute(:data_consistency, data_consistency) validate_worker_attributes! end @@ -105,7 +105,7 @@ module WorkerAttributes # doc/development/sidekiq_style_guide.md#jobs-with-external-dependencies for # details def worker_has_external_dependencies! - class_attributes[:external_dependencies] = true + set_class_attribute(:external_dependencies, true) end # Returns a truthy value if the worker has external dependencies. @@ -118,7 +118,7 @@ module WorkerAttributes def worker_resource_boundary(boundary) raise "Invalid boundary" unless VALID_RESOURCE_BOUNDARIES.include? boundary - class_attributes[:resource_boundary] = boundary + set_class_attribute(:resource_boundary, boundary) end def get_worker_resource_boundary @@ -126,7 +126,7 @@ module WorkerAttributes end def idempotent! - class_attributes[:idempotent] = true + set_class_attribute(:idempotent, true) validate_worker_attributes! end @@ -136,7 +136,7 @@ module WorkerAttributes end def weight(value) - class_attributes[:weight] = value + set_class_attribute(:weight, value) end def get_weight @@ -146,7 +146,7 @@ module WorkerAttributes end def tags(*values) - class_attributes[:tags] = values + set_class_attribute(:tags, values) end def get_tags @@ -154,8 +154,8 @@ module WorkerAttributes end def deduplicate(strategy, options = {}) - class_attributes[:deduplication_strategy] = strategy - class_attributes[:deduplication_options] = options + set_class_attribute(:deduplication_strategy, strategy) + set_class_attribute(:deduplication_options, options) end def get_deduplicate_strategy @@ -168,7 +168,7 @@ module WorkerAttributes end def big_payload! - class_attributes[:big_payload] = true + set_class_attribute(:big_payload, true) end def big_payload? diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb index 53220a7afed..40cc233307a 100644 --- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb +++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb @@ -3,11 +3,14 @@ module ContainerExpirationPolicies class CleanupContainerRepositoryWorker include ApplicationWorker + + sidekiq_options retry: 3 include LimitedCapacity::Worker include Gitlab::Utils::StrongMemoize queue_namespace :container_repository feature_category :container_registry + tags :exclude_from_kubernetes urgency :low worker_resource_boundary :unknown idempotent! @@ -28,7 +31,7 @@ module ContainerExpirationPolicies log_extra_metadata_on_done(:container_repository_id, container_repository.id) log_extra_metadata_on_done(:project_id, project.id) - unless allowed_to_run?(container_repository) + unless allowed_to_run? container_repository.cleanup_unscheduled! log_extra_metadata_on_done(:cleanup_status, :skipped) return @@ -39,9 +42,13 @@ module ContainerExpirationPolicies log_on_done(result) end + def max_running_jobs + return 0 unless throttling_enabled? + + ::Gitlab::CurrentSettings.container_registry_expiration_policies_worker_capacity + end + def remaining_work_count - cleanup_scheduled_count = ContainerRepository.cleanup_scheduled.count - cleanup_unfinished_count = ContainerRepository.cleanup_unfinished.count total_count = cleanup_scheduled_count + cleanup_unfinished_count log_info( @@ -53,50 +60,95 @@ module ContainerExpirationPolicies total_count end - def max_running_jobs - return 0 unless throttling_enabled? + private - ::Gitlab::CurrentSettings.current_application_settings.container_registry_expiration_policies_worker_capacity - end + def container_repository + strong_memoize(:container_repository) do + ContainerRepository.transaction do + # rubocop: disable CodeReuse/ActiveRecord + # We need a lock to prevent two workers from picking up the same row + container_repository = if loopless_enabled? + next_container_repository + else + ContainerRepository.waiting_for_cleanup + .order(:expiration_policy_cleanup_status, :expiration_policy_started_at) + .limit(1) + .lock('FOR UPDATE SKIP LOCKED') + .first + end - private + # rubocop: enable CodeReuse/ActiveRecord + container_repository&.tap(&:cleanup_ongoing!) + end + end + end - def allowed_to_run?(container_repository) - return false unless policy&.enabled && policy&.next_run_at + def next_container_repository + # rubocop: disable CodeReuse/ActiveRecord + next_one_requiring = ContainerRepository.requiring_cleanup + .order(:expiration_policy_cleanup_status, :expiration_policy_started_at) + .limit(1) + .lock('FOR UPDATE SKIP LOCKED') + .first + return next_one_requiring if next_one_requiring + + ContainerRepository.with_unfinished_cleanup + .order(:expiration_policy_started_at) + .limit(1) + .lock('FOR UPDATE SKIP LOCKED') + .first + # rubocop: enable CodeReuse/ActiveRecord + end - Time.zone.now + max_cleanup_execution_time.seconds < policy.next_run_at + def cleanup_scheduled_count + strong_memoize(:cleanup_scheduled_count) do + if loopless_enabled? + limit = max_running_jobs + 1 + ContainerExpirationPolicy.with_container_repositories + .runnable_schedules + .limit(limit) + .count + else + ContainerRepository.cleanup_scheduled.count + end + end end - def throttling_enabled? - Feature.enabled?(:container_registry_expiration_policies_throttling) + def cleanup_unfinished_count + strong_memoize(:cleanup_unfinished_count) do + if loopless_enabled? + limit = max_running_jobs + 1 + ContainerRepository.with_unfinished_cleanup + .limit(limit) + .count + else + ContainerRepository.cleanup_unfinished.count + end + end end - def max_cleanup_execution_time - ::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout + def allowed_to_run? + return false unless policy&.enabled && policy&.next_run_at + + now = Time.zone.now + + if loopless_enabled? + policy.next_run_at < now || (now + max_cleanup_execution_time.seconds < policy.next_run_at) + else + now + max_cleanup_execution_time.seconds < policy.next_run_at + end end - def policy - project.container_expiration_policy + def throttling_enabled? + Feature.enabled?(:container_registry_expiration_policies_throttling) end - def project - container_repository.project + def loopless_enabled? + Feature.enabled?(:container_registry_expiration_policies_loopless) end - def container_repository - strong_memoize(:container_repository) do - ContainerRepository.transaction do - # rubocop: disable CodeReuse/ActiveRecord - # We need a lock to prevent two workers from picking up the same row - container_repository = ContainerRepository.waiting_for_cleanup - .order(:expiration_policy_cleanup_status, :expiration_policy_started_at) - .limit(1) - .lock('FOR UPDATE SKIP LOCKED') - .first - # rubocop: enable CodeReuse/ActiveRecord - container_repository&.tap(&:cleanup_ongoing!) - end - end + def max_cleanup_execution_time + ::Gitlab::CurrentSettings.container_registry_delete_tags_service_timeout end def log_info(extra_structure) @@ -104,6 +156,11 @@ module ContainerExpirationPolicies end def log_on_done(result) + if result.error? + log_extra_metadata_on_done(:cleanup_status, :error) + log_extra_metadata_on_done(:cleanup_error_message, result.message) + end + LOG_ON_DONE_FIELDS.each do |field| value = result.payload[field] @@ -120,5 +177,13 @@ module ContainerExpirationPolicies log_extra_metadata_on_done(:cleanup_tags_service_truncated, !!truncated) log_extra_metadata_on_done(:running_jobs_count, running_jobs_count) end + + def policy + project.container_expiration_policy + end + + def project + container_repository.project + end end end diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb index 5ca89179099..dec13485d13 100644 --- a/app/workers/container_expiration_policy_worker.rb +++ b/app/workers/container_expiration_policy_worker.rb @@ -2,6 +2,8 @@ class ContainerExpirationPolicyWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue include ExclusiveLeaseGuard diff --git a/app/workers/create_commit_signature_worker.rb b/app/workers/create_commit_signature_worker.rb index f81baf20d19..0ba2cc41e99 100644 --- a/app/workers/create_commit_signature_worker.rb +++ b/app/workers/create_commit_signature_worker.rb @@ -3,6 +3,8 @@ class CreateCommitSignatureWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management weight 2 idempotent! @@ -36,7 +38,7 @@ class CreateCommitSignatureWorker # This calculates and caches the signature in the database commits.each do |commit| commit&.signature - rescue => e + rescue StandardError => e Gitlab::AppLogger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}") end end diff --git a/app/workers/create_note_diff_file_worker.rb b/app/workers/create_note_diff_file_worker.rb index 06790cc89d9..0af203fc3bd 100644 --- a/app/workers/create_note_diff_file_worker.rb +++ b/app/workers/create_note_diff_file_worker.rb @@ -3,6 +3,8 @@ class CreateNoteDiffFileWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :code_review def perform(diff_note_id) diff --git a/app/workers/create_pipeline_worker.rb b/app/workers/create_pipeline_worker.rb index 68fe44d01ce..a9072e1661f 100644 --- a/app/workers/create_pipeline_worker.rb +++ b/app/workers/create_pipeline_worker.rb @@ -2,6 +2,8 @@ class CreatePipelineWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_creation diff --git a/app/workers/database/batched_background_migration_worker.rb b/app/workers/database/batched_background_migration_worker.rb index de274d58ad7..5a326a351e8 100644 --- a/app/workers/database/batched_background_migration_worker.rb +++ b/app/workers/database/batched_background_migration_worker.rb @@ -3,9 +3,12 @@ module Database class BatchedBackgroundMigrationWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :database + tags :exclude_from_kubernetes idempotent! LEASE_TIMEOUT_MULTIPLIER = 3 @@ -13,7 +16,7 @@ module Database INTERVAL_VARIANCE = 5.seconds.freeze def perform - return unless Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops) && active_migration + return unless Feature.enabled?(:execute_batched_migrations_on_schedule, type: :ops, default_enabled: :yaml) && active_migration with_exclusive_lease(active_migration.interval) do # Now that we have the exclusive lease, reload migration in case another process has changed it. @@ -38,7 +41,7 @@ module Database end def with_exclusive_lease(interval) - timeout = max(interval * LEASE_TIMEOUT_MULTIPLIER, MINIMUM_LEASE_TIMEOUT) + timeout = [interval * LEASE_TIMEOUT_MULTIPLIER, MINIMUM_LEASE_TIMEOUT].max lease = Gitlab::ExclusiveLease.new(lease_key, timeout: timeout) yield if lease.try_obtain @@ -46,10 +49,6 @@ module Database lease&.cancel end - def max(left, right) - left >= right ? left : right - end - def lease_key self.class.name.demodulize.underscore end diff --git a/app/workers/delete_container_repository_worker.rb b/app/workers/delete_container_repository_worker.rb index dbfc273a5ce..f39f8bf44a4 100644 --- a/app/workers/delete_container_repository_worker.rb +++ b/app/workers/delete_container_repository_worker.rb @@ -2,6 +2,8 @@ class DeleteContainerRepositoryWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ExclusiveLeaseGuard queue_namespace :container_repository diff --git a/app/workers/delete_diff_files_worker.rb b/app/workers/delete_diff_files_worker.rb index 289df8873ec..46dac5d8d39 100644 --- a/app/workers/delete_diff_files_worker.rb +++ b/app/workers/delete_diff_files_worker.rb @@ -3,6 +3,8 @@ class DeleteDiffFilesWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :code_review # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/workers/delete_merged_branches_worker.rb b/app/workers/delete_merged_branches_worker.rb index 8d7026e2d1e..c7e1a4da965 100644 --- a/app/workers/delete_merged_branches_worker.rb +++ b/app/workers/delete_merged_branches_worker.rb @@ -3,6 +3,8 @@ class DeleteMergedBranchesWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management def perform(project_id, user_id) diff --git a/app/workers/delete_stored_files_worker.rb b/app/workers/delete_stored_files_worker.rb index 9cf5631b7d8..75113b4787c 100644 --- a/app/workers/delete_stored_files_worker.rb +++ b/app/workers/delete_stored_files_worker.rb @@ -3,6 +3,8 @@ class DeleteStoredFilesWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category_not_owned! loggable_arguments 0 diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb index ed2e00f1241..f1b9f859ce6 100644 --- a/app/workers/delete_user_worker.rb +++ b/app/workers/delete_user_worker.rb @@ -3,6 +3,8 @@ class DeleteUserWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :authentication_and_authorization loggable_arguments 2 diff --git a/app/workers/deployments/drop_older_deployments_worker.rb b/app/workers/deployments/drop_older_deployments_worker.rb index d6cd92c1da4..6ca819e7942 100644 --- a/app/workers/deployments/drop_older_deployments_worker.rb +++ b/app/workers/deployments/drop_older_deployments_worker.rb @@ -4,8 +4,11 @@ module Deployments class DropOlderDeploymentsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :deployment feature_category :continuous_delivery + tags :exclude_from_kubernetes def perform(deployment_id) Deployments::OlderDeploymentsDropService.new(deployment_id).execute diff --git a/app/workers/deployments/execute_hooks_worker.rb b/app/workers/deployments/execute_hooks_worker.rb index 6be05232321..3046aa28e20 100644 --- a/app/workers/deployments/execute_hooks_worker.rb +++ b/app/workers/deployments/execute_hooks_worker.rb @@ -1,16 +1,19 @@ # frozen_string_literal: true module Deployments + # TODO: remove in https://gitlab.com/gitlab-org/gitlab/-/issues/329360 class ExecuteHooksWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :deployment feature_category :continuous_delivery worker_resource_boundary :cpu def perform(deployment_id) if (deploy = Deployment.find_by_id(deployment_id)) - deploy.execute_hooks + deploy.execute_hooks(Time.current) end end end diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb index 62c886010a3..3de06c381cd 100644 --- a/app/workers/deployments/finished_worker.rb +++ b/app/workers/deployments/finished_worker.rb @@ -6,6 +6,8 @@ module Deployments class FinishedWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :deployment feature_category :continuous_delivery worker_resource_boundary :cpu @@ -13,7 +15,7 @@ module Deployments def perform(deployment_id) if (deploy = Deployment.find_by_id(deployment_id)) LinkMergeRequestsService.new(deploy).execute - deploy.execute_hooks + deploy.execute_hooks(Time.current) end end end diff --git a/app/workers/deployments/forward_deployment_worker.rb b/app/workers/deployments/forward_deployment_worker.rb index dd01fcbbafe..946945051ba 100644 --- a/app/workers/deployments/forward_deployment_worker.rb +++ b/app/workers/deployments/forward_deployment_worker.rb @@ -6,6 +6,8 @@ module Deployments class ForwardDeploymentWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :deployment feature_category :continuous_delivery diff --git a/app/workers/deployments/hooks_worker.rb b/app/workers/deployments/hooks_worker.rb new file mode 100644 index 00000000000..beac44881fb --- /dev/null +++ b/app/workers/deployments/hooks_worker.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Deployments + class HooksWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + queue_namespace :deployment + feature_category :continuous_delivery + + def perform(params = {}) + params = params.with_indifferent_access + + if (deploy = Deployment.find_by_id(params[:deployment_id])) + deploy.execute_hooks(params[:status_changed_at].to_time) + end + end + end +end diff --git a/app/workers/deployments/link_merge_request_worker.rb b/app/workers/deployments/link_merge_request_worker.rb index 4723691a0bb..70947b3f731 100644 --- a/app/workers/deployments/link_merge_request_worker.rb +++ b/app/workers/deployments/link_merge_request_worker.rb @@ -4,6 +4,8 @@ module Deployments class LinkMergeRequestWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :deployment idempotent! feature_category :continuous_delivery diff --git a/app/workers/deployments/success_worker.rb b/app/workers/deployments/success_worker.rb index b72b107985b..eab331433e8 100644 --- a/app/workers/deployments/success_worker.rb +++ b/app/workers/deployments/success_worker.rb @@ -6,6 +6,8 @@ module Deployments class SuccessWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :deployment feature_category :continuous_delivery worker_resource_boundary :cpu diff --git a/app/workers/deployments/update_environment_worker.rb b/app/workers/deployments/update_environment_worker.rb index 2381f9926bc..5c71a13064e 100644 --- a/app/workers/deployments/update_environment_worker.rb +++ b/app/workers/deployments/update_environment_worker.rb @@ -4,6 +4,8 @@ module Deployments class UpdateEnvironmentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :deployment idempotent! feature_category :continuous_delivery diff --git a/app/workers/design_management/copy_design_collection_worker.rb b/app/workers/design_management/copy_design_collection_worker.rb index 0a6e23fe9da..28b511c7c27 100644 --- a/app/workers/design_management/copy_design_collection_worker.rb +++ b/app/workers/design_management/copy_design_collection_worker.rb @@ -4,7 +4,10 @@ module DesignManagement class CopyDesignCollectionWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :design_management + tags :exclude_from_kubernetes idempotent! urgency :low diff --git a/app/workers/design_management/new_version_worker.rb b/app/workers/design_management/new_version_worker.rb index 4fbf2067be4..eee96858c34 100644 --- a/app/workers/design_management/new_version_worker.rb +++ b/app/workers/design_management/new_version_worker.rb @@ -4,6 +4,8 @@ module DesignManagement class NewVersionWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :design_management # Declare this worker as memory bound due to # `GenerateImageVersionsService` resizing designs diff --git a/app/workers/destroy_pages_deployments_worker.rb b/app/workers/destroy_pages_deployments_worker.rb index 32b539325c9..edd446628aa 100644 --- a/app/workers/destroy_pages_deployments_worker.rb +++ b/app/workers/destroy_pages_deployments_worker.rb @@ -8,6 +8,7 @@ class DestroyPagesDeploymentsWorker loggable_arguments 0, 1 sidekiq_options retry: 3 feature_category :pages + tags :exclude_from_kubernetes def perform(project_id, last_deployment_id = nil) project = Project.find_by_id(project_id) diff --git a/app/workers/disallow_two_factor_for_group_worker.rb b/app/workers/disallow_two_factor_for_group_worker.rb index b3cc7a44672..3a48e3ab5da 100644 --- a/app/workers/disallow_two_factor_for_group_worker.rb +++ b/app/workers/disallow_two_factor_for_group_worker.rb @@ -2,9 +2,12 @@ class DisallowTwoFactorForGroupWorker include ApplicationWorker + + sidekiq_options retry: 3 include ExceptionBacktrace feature_category :subgroups + tags :exclude_from_kubernetes idempotent! def perform(group_id) diff --git a/app/workers/disallow_two_factor_for_subgroups_worker.rb b/app/workers/disallow_two_factor_for_subgroups_worker.rb index 1ca227030e2..f5b31e0bcf0 100644 --- a/app/workers/disallow_two_factor_for_subgroups_worker.rb +++ b/app/workers/disallow_two_factor_for_subgroups_worker.rb @@ -2,11 +2,14 @@ class DisallowTwoFactorForSubgroupsWorker include ApplicationWorker + + sidekiq_options retry: 3 include ExceptionBacktrace INTERVAL = 2.seconds.to_i feature_category :subgroups + tags :exclude_from_kubernetes idempotent! def perform(group_id) diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index 9ceab9bb878..37ed1001c9d 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -3,25 +3,86 @@ class EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :issue_tracking urgency :high weight 2 + attr_accessor :raw + def perform(raw) - return unless Gitlab::IncomingEmail.enabled? + return unless should_perform? - begin - Gitlab::Email::Receiver.new(raw).execute - rescue => e - handle_failure(raw, e) - end + @raw = raw + execute_receiver + end + + def should_perform? + Gitlab::IncomingEmail.enabled? end private - def handle_failure(raw, error) - Gitlab::AppLogger.warn("Email can not be processed: #{error}\n\n#{raw}") + def execute_receiver + receiver.execute + log_success + rescue StandardError => e + log_error(e) + handle_failure(e) + end + + def receiver + @receiver ||= Gitlab::Email::Receiver.new(raw) + end + + def logger + Sidekiq.logger + end + + def log_success + logger.info(build_message('Successfully processed message', receiver.mail_metadata)) + end + + def log_error(error) + payload = + case error + # Unparsable e-mails don't have metadata we can use + when Gitlab::Email::EmailUnparsableError, Gitlab::Email::EmptyEmailError + {} + else + mail_metadata + end + + # We don't need the backtrace and more details if the e-mail couldn't be processed + if error.is_a?(Gitlab::Email::ProcessingError) + payload['exception.class'] = error.class.name + else + Gitlab::ExceptionLogFormatter.format!(error, payload) + Gitlab::ErrorTracking.track_exception(error) + end + + logger.error(build_message('Error processing message', payload)) + end + + def build_message(message, params = {}) + { + class: self.class.name, + Labkit::Correlation::CorrelationId::LOG_KEY => Labkit::Correlation::CorrelationId.current_id, + message: message + }.merge(params) + end + + def mail_metadata + receiver.mail_metadata + rescue StandardError => e + # We should never get here as long as we check EmailUnparsableError, but + # let's be defensive in case we did something wrong. + Gitlab::ErrorTracking.track_exception(e) + {} + end + def handle_failure(error) return unless raw.present? can_retry = false diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index 978b65802dd..9c4418c5f31 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -3,6 +3,8 @@ class EmailsOnPushWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + attr_reader :email, :skip_premailer feature_category :source_code_management @@ -56,7 +58,7 @@ class EmailsOnPushWorker # rubocop:disable Scalability/IdempotentWorker end end - EmailsOnPushService.valid_recipients(recipients).each do |recipient| + Integrations::EmailsOnPush.valid_recipients(recipients).each do |recipient| send_email( recipient, project_id, diff --git a/app/workers/environments/auto_stop_cron_worker.rb b/app/workers/environments/auto_stop_cron_worker.rb index ada52d3402d..64028839df1 100644 --- a/app/workers/environments/auto_stop_cron_worker.rb +++ b/app/workers/environments/auto_stop_cron_worker.rb @@ -3,6 +3,8 @@ module Environments class AutoStopCronWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :continuous_delivery diff --git a/app/workers/environments/canary_ingress/update_worker.rb b/app/workers/environments/canary_ingress/update_worker.rb index 53cc38e9eec..ecdfc6f0581 100644 --- a/app/workers/environments/canary_ingress/update_worker.rb +++ b/app/workers/environments/canary_ingress/update_worker.rb @@ -9,6 +9,7 @@ module Environments idempotent! worker_has_external_dependencies! feature_category :continuous_delivery + tags :exclude_from_kubernetes def perform(environment_id, params) Environment.find_by_id(environment_id).try do |environment| diff --git a/app/workers/error_tracking_issue_link_worker.rb b/app/workers/error_tracking_issue_link_worker.rb index 4ad80d57f6b..6c5a96822a6 100644 --- a/app/workers/error_tracking_issue_link_worker.rb +++ b/app/workers/error_tracking_issue_link_worker.rb @@ -7,6 +7,8 @@ # until the prior link is deleted. class ErrorTrackingIssueLinkWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ExclusiveLeaseGuard include Gitlab::Utils::StrongMemoize diff --git a/app/workers/experiments/record_conversion_event_worker.rb b/app/workers/experiments/record_conversion_event_worker.rb index e38ce7b3d01..9fc76a2173b 100644 --- a/app/workers/experiments/record_conversion_event_worker.rb +++ b/app/workers/experiments/record_conversion_event_worker.rb @@ -4,7 +4,10 @@ module Experiments class RecordConversionEventWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :users + tags :exclude_from_kubernetes urgency :low idempotent! diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb index 50fdd046491..a9fa94ef301 100644 --- a/app/workers/expire_build_artifacts_worker.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -2,6 +2,8 @@ class ExpireBuildArtifactsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/expire_build_instance_artifacts_worker.rb b/app/workers/expire_build_instance_artifacts_worker.rb index e6cd60a3e47..3e6e81867bd 100644 --- a/app/workers/expire_build_instance_artifacts_worker.rb +++ b/app/workers/expire_build_instance_artifacts_worker.rb @@ -3,6 +3,8 @@ class ExpireBuildInstanceArtifactsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :continuous_integration # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb index 48bb1160ae8..074c35997f6 100644 --- a/app/workers/expire_job_cache_worker.rb +++ b/app/workers/expire_job_cache_worker.rb @@ -2,6 +2,8 @@ class ExpireJobCacheWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_cache diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index cbea46cdccd..3c48c4ba3cd 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -2,6 +2,8 @@ class ExpirePipelineCacheWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_cache diff --git a/app/workers/export_csv_worker.rb b/app/workers/export_csv_worker.rb index f2da381a34a..a2ad0cb92fd 100644 --- a/app/workers/export_csv_worker.rb +++ b/app/workers/export_csv_worker.rb @@ -3,6 +3,8 @@ class ExportCsvWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :issue_tracking worker_resource_boundary :cpu loggable_arguments 2 diff --git a/app/workers/flush_counter_increments_worker.rb b/app/workers/flush_counter_increments_worker.rb index b7e3c0c134d..44d30b4ba3d 100644 --- a/app/workers/flush_counter_increments_worker.rb +++ b/app/workers/flush_counter_increments_worker.rb @@ -8,7 +8,10 @@ class FlushCounterIncrementsWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category_not_owned! + tags :exclude_from_kubernetes urgency :low deduplicate :until_executing, including_scheduled: true diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb deleted file mode 100644 index a2aab23db7b..00000000000 --- a/app/workers/git_garbage_collect_worker.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -# According to our docs, we can only remove workers on major releases -# https://docs.gitlab.com/ee/development/sidekiq_style_guide.html#removing-workers. -# -# We need to still maintain this until 14.0 but with the current functionality. -# -# In https://gitlab.com/gitlab-org/gitlab/-/issues/299290 we track that removal. -class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - sidekiq_options retry: false - feature_category :gitaly - loggable_arguments 1, 2, 3 - - def perform(project_id, task = :gc, lease_key = nil, lease_uuid = nil) - ::Projects::GitGarbageCollectWorker.new.perform(project_id, task, lease_key, lease_uuid) - end -end diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb index af406b32415..f25296f0461 100644 --- a/app/workers/gitlab/github_import/advance_stage_worker.rb +++ b/app/workers/gitlab/github_import/advance_stage_worker.rb @@ -8,6 +8,8 @@ module Gitlab # stage. class AdvanceStageWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ::Gitlab::Import::AdvanceStage sidekiq_options dead: false diff --git a/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb b/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb index 79ef917bbc5..a8b79cf9b3a 100644 --- a/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb +++ b/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb @@ -5,6 +5,8 @@ module Gitlab class ImportPullRequestMergedByWorker # rubocop:disable Scalability/IdempotentWorker include ObjectImporter + tags :exclude_from_kubernetes + def representation_class Gitlab::GithubImport::Representation::PullRequest end diff --git a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb index b8516fb8670..5ee88d5d32b 100644 --- a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb +++ b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb @@ -5,6 +5,8 @@ module Gitlab class ImportPullRequestReviewWorker # rubocop:disable Scalability/IdempotentWorker include ObjectImporter + tags :exclude_from_kubernetes + def representation_class Gitlab::GithubImport::Representation::PullRequestReview end diff --git a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb index 0ddd893d0d1..1c769921ab3 100644 --- a/app/workers/gitlab/github_import/refresh_import_jid_worker.rb +++ b/app/workers/gitlab/github_import/refresh_import_jid_worker.rb @@ -4,6 +4,8 @@ module Gitlab module GithubImport class RefreshImportJidWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include GithubImport::Queue # The interval to schedule new instances of this job at. diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb index 058e1a0853d..f5980cc248e 100644 --- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb +++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb @@ -5,6 +5,8 @@ module Gitlab module Stage class FinishImportWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb index 202bb335ca1..7ca23ecad20 100644 --- a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb @@ -5,6 +5,8 @@ module Gitlab module Stage class ImportBaseDataWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb index 486057804b4..d66698277b0 100644 --- a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb @@ -5,6 +5,8 @@ module Gitlab module Stage class ImportIssuesAndDiffNotesWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb index de2a7f9fc29..2a66a08d534 100644 --- a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb @@ -5,6 +5,8 @@ module Gitlab module Stage class ImportLfsObjectsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb index e1da26a9d48..873e389fca6 100644 --- a/app/workers/gitlab/github_import/stage/import_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb @@ -5,6 +5,8 @@ module Gitlab module Stage class ImportNotesWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb index 3e15c346659..5743648680d 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb @@ -5,9 +5,13 @@ module Gitlab module Stage class ImportPullRequestsMergedByWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include GithubImport::Queue include StageMethods + tags :exclude_from_kubernetes + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb index 790e8b0eccf..532d550f190 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb @@ -5,9 +5,13 @@ module Gitlab module Stage class ImportPullRequestsReviewsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include GithubImport::Queue include StageMethods + tags :exclude_from_kubernetes + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb index bf2defa6326..5755aea21ce 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb @@ -5,6 +5,8 @@ module Gitlab module Stage class ImportPullRequestsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb index 3338f7e58c0..e113563ce8b 100644 --- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb @@ -5,6 +5,8 @@ module Gitlab module Stage class ImportRepositoryWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include GithubImport::Queue include StageMethods diff --git a/app/workers/gitlab/import/stuck_import_job.rb b/app/workers/gitlab/import/stuck_import_job.rb index 16be7a77ab1..ac789ce1188 100644 --- a/app/workers/gitlab/import/stuck_import_job.rb +++ b/app/workers/gitlab/import/stuck_import_job.rb @@ -9,6 +9,8 @@ module Gitlab included do include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker updates several import states inline and does not schedule # other jobs. So no context needed diff --git a/app/workers/gitlab/jira_import/advance_stage_worker.rb b/app/workers/gitlab/jira_import/advance_stage_worker.rb index c3a64669c60..6387054d448 100644 --- a/app/workers/gitlab/jira_import/advance_stage_worker.rb +++ b/app/workers/gitlab/jira_import/advance_stage_worker.rb @@ -4,6 +4,8 @@ module Gitlab module JiraImport class AdvanceStageWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include QueueOptions include ::Gitlab::Import::AdvanceStage diff --git a/app/workers/gitlab/jira_import/import_issue_worker.rb b/app/workers/gitlab/jira_import/import_issue_worker.rb index d1ceda4fd6a..98bde2218c2 100644 --- a/app/workers/gitlab/jira_import/import_issue_worker.rb +++ b/app/workers/gitlab/jira_import/import_issue_worker.rb @@ -4,6 +4,8 @@ module Gitlab module JiraImport class ImportIssueWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include NotifyUponDeath include Gitlab::JiraImport::QueueOptions include Gitlab::Import::DatabaseHelpers @@ -13,7 +15,7 @@ module Gitlab def perform(project_id, jira_issue_id, issue_attributes, waiter_key) issue_id = create_issue(issue_attributes, project_id) JiraImport.cache_issue_mapping(issue_id, jira_issue_id, project_id) - rescue => ex + rescue StandardError => ex # Todo: Record jira issue id(or better jira issue key), # so that we can report the list of failed to import issues to the user # see https://gitlab.com/gitlab-org/gitlab/-/issues/211653 diff --git a/app/workers/gitlab/jira_import/stage/start_import_worker.rb b/app/workers/gitlab/jira_import/stage/start_import_worker.rb index bfc02224ee4..e327ced8c65 100644 --- a/app/workers/gitlab/jira_import/stage/start_import_worker.rb +++ b/app/workers/gitlab/jira_import/stage/start_import_worker.rb @@ -5,6 +5,8 @@ module Gitlab module Stage class StartImportWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ProjectStartImport include ProjectImportOptions include Gitlab::JiraImport::QueueOptions diff --git a/app/workers/gitlab/phabricator_import/import_tasks_worker.rb b/app/workers/gitlab/phabricator_import/import_tasks_worker.rb index 1b1d7b35dd5..867a12fbac2 100644 --- a/app/workers/gitlab/phabricator_import/import_tasks_worker.rb +++ b/app/workers/gitlab/phabricator_import/import_tasks_worker.rb @@ -3,6 +3,8 @@ module Gitlab module PhabricatorImport class ImportTasksWorker < BaseWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ProjectImportOptions # This marks the project as failed after too many tries def importer_class diff --git a/app/workers/gitlab_performance_bar_stats_worker.rb b/app/workers/gitlab_performance_bar_stats_worker.rb index 558df0ab7b3..4f7fdcf96f0 100644 --- a/app/workers/gitlab_performance_bar_stats_worker.rb +++ b/app/workers/gitlab_performance_bar_stats_worker.rb @@ -3,6 +3,8 @@ class GitlabPerformanceBarStatsWorker include ApplicationWorker + sidekiq_options retry: 3 + LEASE_KEY = 'gitlab:performance_bar_stats' LEASE_TIMEOUT = 600 WORKER_DELAY = 120 @@ -10,6 +12,7 @@ class GitlabPerformanceBarStatsWorker STATS_KEY_EXPIRE = 30.minutes.to_i feature_category :metrics + tags :exclude_from_kubernetes idempotent! def perform(lease_uuid) diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb index b8e1e3d8fc4..de1e9af7bae 100644 --- a/app/workers/gitlab_shell_worker.rb +++ b/app/workers/gitlab_shell_worker.rb @@ -2,6 +2,8 @@ class GitlabShellWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include Gitlab::ShellAdapter feature_category :source_code_management diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb index 901785f462b..2c140c89e26 100644 --- a/app/workers/group_destroy_worker.rb +++ b/app/workers/group_destroy_worker.rb @@ -2,10 +2,12 @@ class GroupDestroyWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ExceptionBacktrace feature_category :subgroups - tags :requires_disk_io + tags :requires_disk_io, :exclude_from_kubernetes def perform(group_id, user_id) begin diff --git a/app/workers/hashed_storage/migrator_worker.rb b/app/workers/hashed_storage/migrator_worker.rb index 5cbdfcb0602..735d8a2447a 100644 --- a/app/workers/hashed_storage/migrator_worker.rb +++ b/app/workers/hashed_storage/migrator_worker.rb @@ -4,8 +4,11 @@ module HashedStorage class MigratorWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :hashed_storage feature_category :source_code_management + tags :exclude_from_gitlab_com # @param [Integer] start initial ID of the batch # @param [Integer] finish last ID of the batch diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb index 03e53058dbb..0659c8a6a46 100644 --- a/app/workers/hashed_storage/project_migrate_worker.rb +++ b/app/workers/hashed_storage/project_migrate_worker.rb @@ -4,8 +4,11 @@ module HashedStorage class ProjectMigrateWorker < BaseWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :hashed_storage loggable_arguments 1 + tags :exclude_from_gitlab_com attr_reader :project_id diff --git a/app/workers/hashed_storage/project_rollback_worker.rb b/app/workers/hashed_storage/project_rollback_worker.rb index d4a5e474323..a5ee8b35176 100644 --- a/app/workers/hashed_storage/project_rollback_worker.rb +++ b/app/workers/hashed_storage/project_rollback_worker.rb @@ -4,8 +4,11 @@ module HashedStorage class ProjectRollbackWorker < BaseWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :hashed_storage loggable_arguments 1 + tags :exclude_from_gitlab_com attr_reader :project_id diff --git a/app/workers/hashed_storage/rollbacker_worker.rb b/app/workers/hashed_storage/rollbacker_worker.rb index a220d3b2226..447bdfa6220 100644 --- a/app/workers/hashed_storage/rollbacker_worker.rb +++ b/app/workers/hashed_storage/rollbacker_worker.rb @@ -4,8 +4,11 @@ module HashedStorage class RollbackerWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :hashed_storage feature_category :source_code_management + tags :exclude_from_gitlab_com # @param [Integer] start initial ID of the batch # @param [Integer] finish last ID of the batch diff --git a/app/workers/import_export_project_cleanup_worker.rb b/app/workers/import_export_project_cleanup_worker.rb index dd345434d08..6e112a47932 100644 --- a/app/workers/import_export_project_cleanup_worker.rb +++ b/app/workers/import_export_project_cleanup_worker.rb @@ -2,6 +2,8 @@ class ImportExportProjectCleanupWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb index 521e5b8fbc2..46b59dc398f 100644 --- a/app/workers/import_issues_csv_worker.rb +++ b/app/workers/import_issues_csv_worker.rb @@ -3,6 +3,8 @@ class ImportIssuesCsvWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + idempotent! feature_category :issue_tracking worker_resource_boundary :cpu diff --git a/app/workers/incident_management/add_severity_system_note_worker.rb b/app/workers/incident_management/add_severity_system_note_worker.rb index 9f132531562..62ed902e488 100644 --- a/app/workers/incident_management/add_severity_system_note_worker.rb +++ b/app/workers/incident_management/add_severity_system_note_worker.rb @@ -4,8 +4,11 @@ module IncidentManagement class AddSeveritySystemNoteWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :incident_management feature_category :incident_management + tags :exclude_from_kubernetes def perform(incident_id, user_id) return if incident_id.blank? || user_id.blank? diff --git a/app/workers/incident_management/pager_duty/process_incident_worker.rb b/app/workers/incident_management/pager_duty/process_incident_worker.rb index 3f378b012a1..413a297a024 100644 --- a/app/workers/incident_management/pager_duty/process_incident_worker.rb +++ b/app/workers/incident_management/pager_duty/process_incident_worker.rb @@ -5,6 +5,8 @@ module IncidentManagement class ProcessIncidentWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :incident_management feature_category :incident_management diff --git a/app/workers/incident_management/process_alert_worker.rb b/app/workers/incident_management/process_alert_worker.rb index 59464b81d1b..3b90e296ad4 100644 --- a/app/workers/incident_management/process_alert_worker.rb +++ b/app/workers/incident_management/process_alert_worker.rb @@ -4,12 +4,20 @@ module IncidentManagement class ProcessAlertWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :incident_management feature_category :incident_management # `project_id` and `alert_payload` are deprecated and can be removed # starting from 14.0 release # https://gitlab.com/gitlab-org/gitlab/-/issues/224500 + # + # This worker is not scheduled anymore since + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60285 + # and will be removed completely via + # https://gitlab.com/gitlab-org/gitlab/-/issues/224500 + # in 14.0. def perform(_project_id = nil, _alert_payload = nil, alert_id = nil) return unless alert_id diff --git a/app/workers/incident_management/process_alert_worker_v2.rb b/app/workers/incident_management/process_alert_worker_v2.rb new file mode 100644 index 00000000000..04bf6970578 --- /dev/null +++ b/app/workers/incident_management/process_alert_worker_v2.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module IncidentManagement + class ProcessAlertWorkerV2 # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + queue_namespace :incident_management + feature_category :incident_management + + idempotent! + + def perform(alert_id) + return unless alert_id + + alert = find_alert(alert_id) + return unless alert + + result = create_issue_for(alert) + return if result.success? + + log_warning(alert, result) + end + + private + + def find_alert(alert_id) + AlertManagement::Alert.find_by_id(alert_id) + end + + def create_issue_for(alert) + AlertManagement::CreateAlertIssueService + .new(alert, User.alert_bot) + .execute + end + + def log_warning(alert, result) + issue_id = result.payload[:issue]&.id + + Gitlab::AppLogger.warn( + message: 'Cannot process an Incident', + issue_id: issue_id, + alert_id: alert.id, + errors: result.message + ) + end + end +end diff --git a/app/workers/incident_management/process_prometheus_alert_worker.rb b/app/workers/incident_management/process_prometheus_alert_worker.rb index 4b778f6a621..7b5c6fd9001 100644 --- a/app/workers/incident_management/process_prometheus_alert_worker.rb +++ b/app/workers/incident_management/process_prometheus_alert_worker.rb @@ -4,6 +4,8 @@ module IncidentManagement class ProcessPrometheusAlertWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :incident_management feature_category :incident_management worker_resource_boundary :cpu diff --git a/app/workers/invalid_gpg_signature_update_worker.rb b/app/workers/invalid_gpg_signature_update_worker.rb index 1fd959c8763..662817b5a92 100644 --- a/app/workers/invalid_gpg_signature_update_worker.rb +++ b/app/workers/invalid_gpg_signature_update_worker.rb @@ -3,6 +3,8 @@ class InvalidGpgSignatureUpdateWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management weight 2 diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb index c5bdb3e0970..4378da186a7 100644 --- a/app/workers/irker_worker.rb +++ b/app/workers/irker_worker.rb @@ -6,6 +6,8 @@ require 'socket' class IrkerWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :integrations def perform(project_id, channels, colors, push_data, settings) diff --git a/app/workers/issuable/label_links_destroy_worker.rb b/app/workers/issuable/label_links_destroy_worker.rb new file mode 100644 index 00000000000..f663c410fba --- /dev/null +++ b/app/workers/issuable/label_links_destroy_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Issuable + class LabelLinksDestroyWorker + include ApplicationWorker + + idempotent! + feature_category :issue_tracking + + def perform(target_id, target_type) + ::Issuable::DestroyLabelLinksService.new(target_id, target_type).execute + end + end +end diff --git a/app/workers/issuable_export_csv_worker.rb b/app/workers/issuable_export_csv_worker.rb index eb96a78497c..41facab6bb9 100644 --- a/app/workers/issuable_export_csv_worker.rb +++ b/app/workers/issuable_export_csv_worker.rb @@ -3,6 +3,8 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :issue_tracking worker_resource_boundary :cpu loggable_arguments 2 @@ -47,4 +49,4 @@ class IssuableExportCsvWorker # rubocop:disable Scalability/IdempotentWorker end end -IssuableExportCsvWorker.prepend_if_ee('::EE::IssuableExportCsvWorker') +IssuableExportCsvWorker.prepend_mod_with('IssuableExportCsvWorker') diff --git a/app/workers/issuables/clear_groups_issue_counter_worker.rb b/app/workers/issuables/clear_groups_issue_counter_worker.rb new file mode 100644 index 00000000000..a8d6fd2f870 --- /dev/null +++ b/app/workers/issuables/clear_groups_issue_counter_worker.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Issuables + class ClearGroupsIssueCounterWorker + include ApplicationWorker + + idempotent! + urgency :low + feature_category :issue_tracking + + def perform(group_ids = []) + return if group_ids.empty? + + groups_with_ancestors = Gitlab::ObjectHierarchy + .new(Group.by_id(group_ids)) + .base_and_ancestors + + clear_cached_count(groups_with_ancestors) + end + + private + + def clear_cached_count(groups) + groups.each do |group| + Groups::OpenIssuesCountService.new(group).clear_all_cache_keys + end + end + end +end diff --git a/app/workers/issue_due_scheduler_worker.rb b/app/workers/issue_due_scheduler_worker.rb index d735295d046..9077b42d645 100644 --- a/app/workers/issue_due_scheduler_worker.rb +++ b/app/workers/issue_due_scheduler_worker.rb @@ -2,6 +2,8 @@ class IssueDueSchedulerWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :issue_tracking diff --git a/app/workers/issue_placement_worker.rb b/app/workers/issue_placement_worker.rb index 5b547ab0c8d..dba791c3f05 100644 --- a/app/workers/issue_placement_worker.rb +++ b/app/workers/issue_placement_worker.rb @@ -3,7 +3,10 @@ class IssuePlacementWorker include ApplicationWorker + sidekiq_options retry: 3 + idempotent! + deduplicate :until_executed, including_scheduled: true feature_category :issue_tracking urgency :high worker_resource_boundary :cpu @@ -17,6 +20,10 @@ class IssuePlacementWorker issue = find_issue(issue_id, project_id) return unless issue + # Temporary disable moving null elements because of performance problems + # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321 + return if issue.blocked_for_repositioning? + # Move the oldest 100 unpositioned items to the end. # This is to deal with out-of-order execution of the worker, # while preserving creation order. @@ -30,7 +37,7 @@ class IssuePlacementWorker leftover = to_place.pop if to_place.count > QUERY_LIMIT Issue.move_nulls_to_end(to_place) - Issues::BaseService.new(nil).rebalance_if_needed(to_place.max_by(&:relative_position)) + Issues::BaseService.new(project: nil).rebalance_if_needed(to_place.max_by(&:relative_position)) IssuePlacementWorker.perform_async(nil, leftover.project_id) if leftover.present? rescue RelativePositioning::NoSpaceLeft => e Gitlab::ErrorTracking.log_exception(e, issue_id: issue_id, project_id: project_id) diff --git a/app/workers/issue_rebalancing_worker.rb b/app/workers/issue_rebalancing_worker.rb index a9ad66198f3..9eac451f107 100644 --- a/app/workers/issue_rebalancing_worker.rb +++ b/app/workers/issue_rebalancing_worker.rb @@ -3,14 +3,22 @@ class IssueRebalancingWorker include ApplicationWorker + sidekiq_options retry: 3 + idempotent! urgency :low feature_category :issue_tracking + tags :exclude_from_kubernetes def perform(ignore = nil, project_id = nil) return if project_id.nil? project = Project.find(project_id) + + # Temporary disable reabalancing for performance reasons + # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321 + return if project.root_namespace&.issue_repositioning_disabled? + # All issues are equivalent as far as we are concerned issue = project.issues.take # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/workers/jira_connect/sync_branch_worker.rb b/app/workers/jira_connect/sync_branch_worker.rb index 1af51c4bb74..b8211286d1c 100644 --- a/app/workers/jira_connect/sync_branch_worker.rb +++ b/app/workers/jira_connect/sync_branch_worker.rb @@ -4,6 +4,8 @@ module JiraConnect class SyncBranchWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :jira_connect feature_category :integrations loggable_arguments 1, 2 diff --git a/app/workers/jira_connect/sync_builds_worker.rb b/app/workers/jira_connect/sync_builds_worker.rb index 9cb5d5d247d..11a3b598035 100644 --- a/app/workers/jira_connect/sync_builds_worker.rb +++ b/app/workers/jira_connect/sync_builds_worker.rb @@ -4,11 +4,14 @@ module JiraConnect class SyncBuildsWorker include ApplicationWorker + sidekiq_options retry: 3 + idempotent! worker_has_external_dependencies! queue_namespace :jira_connect feature_category :integrations + tags :exclude_from_kubernetes def perform(pipeline_id, sequence_id) pipeline = Ci::Pipeline.find_by_id(pipeline_id) diff --git a/app/workers/jira_connect/sync_deployments_worker.rb b/app/workers/jira_connect/sync_deployments_worker.rb index 7272d35f4cb..9f75b1161f0 100644 --- a/app/workers/jira_connect/sync_deployments_worker.rb +++ b/app/workers/jira_connect/sync_deployments_worker.rb @@ -4,11 +4,14 @@ module JiraConnect class SyncDeploymentsWorker include ApplicationWorker + sidekiq_options retry: 3 + idempotent! worker_has_external_dependencies! queue_namespace :jira_connect feature_category :integrations + tags :exclude_from_kubernetes def perform(deployment_id, sequence_id) deployment = Deployment.find_by_id(deployment_id) diff --git a/app/workers/jira_connect/sync_feature_flags_worker.rb b/app/workers/jira_connect/sync_feature_flags_worker.rb index 496b9f1626d..0d8d3d3142e 100644 --- a/app/workers/jira_connect/sync_feature_flags_worker.rb +++ b/app/workers/jira_connect/sync_feature_flags_worker.rb @@ -4,11 +4,14 @@ module JiraConnect class SyncFeatureFlagsWorker include ApplicationWorker + sidekiq_options retry: 3 + idempotent! worker_has_external_dependencies! queue_namespace :jira_connect feature_category :integrations + tags :exclude_from_kubernetes def perform(feature_flag_id, sequence_id) feature_flag = ::Operations::FeatureFlag.find_by_id(feature_flag_id) diff --git a/app/workers/jira_connect/sync_merge_request_worker.rb b/app/workers/jira_connect/sync_merge_request_worker.rb index 543d8e002fe..6b3a6ae84ad 100644 --- a/app/workers/jira_connect/sync_merge_request_worker.rb +++ b/app/workers/jira_connect/sync_merge_request_worker.rb @@ -4,6 +4,8 @@ module JiraConnect class SyncMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :jira_connect feature_category :integrations idempotent! diff --git a/app/workers/jira_connect/sync_project_worker.rb b/app/workers/jira_connect/sync_project_worker.rb index 4d52705f207..dfff0c4b3b6 100644 --- a/app/workers/jira_connect/sync_project_worker.rb +++ b/app/workers/jira_connect/sync_project_worker.rb @@ -4,8 +4,11 @@ module JiraConnect class SyncProjectWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :jira_connect feature_category :integrations + tags :exclude_from_kubernetes idempotent! worker_has_external_dependencies! diff --git a/app/workers/mail_scheduler/issue_due_worker.rb b/app/workers/mail_scheduler/issue_due_worker.rb index 309d3e13477..44f8f1e446c 100644 --- a/app/workers/mail_scheduler/issue_due_worker.rb +++ b/app/workers/mail_scheduler/issue_due_worker.rb @@ -3,6 +3,8 @@ module MailScheduler class IssueDueWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include MailSchedulerQueue feature_category :issue_tracking diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index 309f23c8708..8645cc93511 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -5,6 +5,8 @@ require 'active_job/arguments' module MailScheduler class NotificationServiceWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include MailSchedulerQueue feature_category :issue_tracking diff --git a/app/workers/member_invitation_reminder_emails_worker.rb b/app/workers/member_invitation_reminder_emails_worker.rb index 971d6abaa51..bfee8ab1fab 100644 --- a/app/workers/member_invitation_reminder_emails_worker.rb +++ b/app/workers/member_invitation_reminder_emails_worker.rb @@ -2,9 +2,12 @@ class MemberInvitationReminderEmailsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :subgroups + tags :exclude_from_kubernetes urgency :low def perform diff --git a/app/workers/members_destroyer/unassign_issuables_worker.rb b/app/workers/members_destroyer/unassign_issuables_worker.rb index 2c17120bf48..0ee5654eaba 100644 --- a/app/workers/members_destroyer/unassign_issuables_worker.rb +++ b/app/workers/members_destroyer/unassign_issuables_worker.rb @@ -4,6 +4,8 @@ module MembersDestroyer class UnassignIssuablesWorker include ApplicationWorker + sidekiq_options retry: 3 + ENTITY_TYPES = %w(Group Project).freeze queue_namespace :unassign_issuables diff --git a/app/workers/merge_request_cleanup_refs_worker.rb b/app/workers/merge_request_cleanup_refs_worker.rb index fbd62ac0a91..162c6dc2a88 100644 --- a/app/workers/merge_request_cleanup_refs_worker.rb +++ b/app/workers/merge_request_cleanup_refs_worker.rb @@ -3,7 +3,10 @@ class MergeRequestCleanupRefsWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :code_review + tags :exclude_from_kubernetes idempotent! def perform(merge_request_id) diff --git a/app/workers/merge_request_mergeability_check_worker.rb b/app/workers/merge_request_mergeability_check_worker.rb index 70d5f49d70e..13961de1f59 100644 --- a/app/workers/merge_request_mergeability_check_worker.rb +++ b/app/workers/merge_request_mergeability_check_worker.rb @@ -3,6 +3,8 @@ class MergeRequestMergeabilityCheckWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :code_review idempotent! diff --git a/app/workers/merge_requests/assignees_change_worker.rb b/app/workers/merge_requests/assignees_change_worker.rb index 9865563e357..fe39f20151f 100644 --- a/app/workers/merge_requests/assignees_change_worker.rb +++ b/app/workers/merge_requests/assignees_change_worker.rb @@ -3,6 +3,8 @@ class MergeRequests::AssigneesChangeWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management urgency :high deduplicate :until_executed @@ -19,7 +21,7 @@ class MergeRequests::AssigneesChangeWorker return if users.blank? ::MergeRequests::HandleAssigneesChangeService - .new(merge_request.target_project, current_user) + .new(project: merge_request.target_project, current_user: current_user) .execute(merge_request, users, execute_hooks: true) rescue ActiveRecord::RecordNotFound end diff --git a/app/workers/merge_requests/create_pipeline_worker.rb b/app/workers/merge_requests/create_pipeline_worker.rb index 244ba1af300..a79a92a5419 100644 --- a/app/workers/merge_requests/create_pipeline_worker.rb +++ b/app/workers/merge_requests/create_pipeline_worker.rb @@ -3,6 +3,8 @@ module MergeRequests class CreatePipelineWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_creation @@ -21,7 +23,7 @@ module MergeRequests merge_request = MergeRequest.find_by_id(merge_request_id) return unless merge_request - MergeRequests::CreatePipelineService.new(project, user).execute(merge_request) + MergeRequests::CreatePipelineService.new(project: project, current_user: user).execute(merge_request) merge_request.update_head_pipeline end end diff --git a/app/workers/merge_requests/delete_source_branch_worker.rb b/app/workers/merge_requests/delete_source_branch_worker.rb index eb83d10af33..1ce3a99b298 100644 --- a/app/workers/merge_requests/delete_source_branch_worker.rb +++ b/app/workers/merge_requests/delete_source_branch_worker.rb @@ -3,6 +3,8 @@ class MergeRequests::DeleteSourceBranchWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management urgency :high idempotent! @@ -17,7 +19,7 @@ class MergeRequests::DeleteSourceBranchWorker ::Branches::DeleteService.new(merge_request.source_project, user) .execute(merge_request.source_branch) - ::MergeRequests::RetargetChainService.new(merge_request.source_project, user) + ::MergeRequests::RetargetChainService.new(project: merge_request.source_project, current_user: user) .execute(merge_request) rescue ActiveRecord::RecordNotFound end diff --git a/app/workers/merge_requests/handle_assignees_change_worker.rb b/app/workers/merge_requests/handle_assignees_change_worker.rb index e79d8293bae..4c0500cd520 100644 --- a/app/workers/merge_requests/handle_assignees_change_worker.rb +++ b/app/workers/merge_requests/handle_assignees_change_worker.rb @@ -3,6 +3,8 @@ class MergeRequests::HandleAssigneesChangeWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :code_review urgency :high deduplicate :until_executed @@ -15,7 +17,7 @@ class MergeRequests::HandleAssigneesChangeWorker old_assignees = User.id_in(old_assignee_ids) ::MergeRequests::HandleAssigneesChangeService - .new(merge_request.target_project, user) + .new(project: merge_request.target_project, current_user: user) .execute(merge_request, old_assignees, options) rescue ActiveRecord::RecordNotFound end diff --git a/app/workers/merge_requests/resolve_todos_worker.rb b/app/workers/merge_requests/resolve_todos_worker.rb index 2a5f742f809..8bb88091efe 100644 --- a/app/workers/merge_requests/resolve_todos_worker.rb +++ b/app/workers/merge_requests/resolve_todos_worker.rb @@ -3,6 +3,8 @@ class MergeRequests::ResolveTodosWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :code_review urgency :high deduplicate :until_executed diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index 270bd831f96..df5a7a904fc 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -3,17 +3,27 @@ class MergeWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management urgency :high weight 5 loggable_arguments 2 + idempotent! + + deduplicate :until_executed, including_scheduled: true def perform(merge_request_id, current_user_id, params) params = params.with_indifferent_access - current_user = User.find(current_user_id) - merge_request = MergeRequest.find(merge_request_id) - MergeRequests::MergeService.new(merge_request.target_project, current_user, params) + begin + current_user = User.find(current_user_id) + merge_request = MergeRequest.find(merge_request_id) + rescue ActiveRecord::RecordNotFound + return + end + + MergeRequests::MergeService.new(project: merge_request.target_project, current_user: current_user, params: params) .execute(merge_request) end end diff --git a/app/workers/metrics/dashboard/prune_old_annotations_worker.rb b/app/workers/metrics/dashboard/prune_old_annotations_worker.rb index 2a9ce3bb8e6..5e8067a4438 100644 --- a/app/workers/metrics/dashboard/prune_old_annotations_worker.rb +++ b/app/workers/metrics/dashboard/prune_old_annotations_worker.rb @@ -5,6 +5,8 @@ module Metrics class PruneOldAnnotationsWorker include ApplicationWorker + sidekiq_options retry: 3 + DELETE_LIMIT = 10_000 DEFAULT_CUT_OFF_PERIOD = 2.weeks diff --git a/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb b/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb index cbdd69c6e8c..6f2ff8cca13 100644 --- a/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb +++ b/app/workers/metrics/dashboard/schedule_annotations_prune_worker.rb @@ -4,6 +4,8 @@ module Metrics module Dashboard class ScheduleAnnotationsPruneWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/metrics/dashboard/sync_dashboards_worker.rb b/app/workers/metrics/dashboard/sync_dashboards_worker.rb index 7a124a33f9e..0fdc7825f47 100644 --- a/app/workers/metrics/dashboard/sync_dashboards_worker.rb +++ b/app/workers/metrics/dashboard/sync_dashboards_worker.rb @@ -5,7 +5,10 @@ module Metrics class SyncDashboardsWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :metrics + tags :exclude_from_kubernetes idempotent! diff --git a/app/workers/migrate_external_diffs_worker.rb b/app/workers/migrate_external_diffs_worker.rb index 3ef399bd9fc..a73a9be4f0c 100644 --- a/app/workers/migrate_external_diffs_worker.rb +++ b/app/workers/migrate_external_diffs_worker.rb @@ -3,6 +3,8 @@ class MigrateExternalDiffsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :code_review def perform(merge_request_diff_id) diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb index 1c8054d8fbd..91cad6f2a5c 100644 --- a/app/workers/namespaceless_project_destroy_worker.rb +++ b/app/workers/namespaceless_project_destroy_worker.rb @@ -8,6 +8,8 @@ # namespace. For those use ProjectDestroyWorker instead. class NamespacelessProjectDestroyWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ExceptionBacktrace feature_category :authentication_and_authorization diff --git a/app/workers/namespaces/in_product_marketing_emails_worker.rb b/app/workers/namespaces/in_product_marketing_emails_worker.rb index 3070afed3d6..7985325d1ad 100644 --- a/app/workers/namespaces/in_product_marketing_emails_worker.rb +++ b/app/workers/namespaces/in_product_marketing_emails_worker.rb @@ -3,9 +3,12 @@ module Namespaces class InProductMarketingEmailsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :subgroups + tags :exclude_from_kubernetes urgency :low def perform @@ -32,4 +35,4 @@ module Namespaces end end -Namespaces::InProductMarketingEmailsWorker.prepend_if_ee('EE::Namespaces::InProductMarketingEmailsWorker') +Namespaces::InProductMarketingEmailsWorker.prepend_mod_with('Namespaces::InProductMarketingEmailsWorker') diff --git a/app/workers/namespaces/onboarding_issue_created_worker.rb b/app/workers/namespaces/onboarding_issue_created_worker.rb index e5e2c80e821..7b8b1a43078 100644 --- a/app/workers/namespaces/onboarding_issue_created_worker.rb +++ b/app/workers/namespaces/onboarding_issue_created_worker.rb @@ -4,7 +4,10 @@ module Namespaces class OnboardingIssueCreatedWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :issue_tracking + tags :exclude_from_kubernetes urgency :low deduplicate :until_executing diff --git a/app/workers/namespaces/onboarding_pipeline_created_worker.rb b/app/workers/namespaces/onboarding_pipeline_created_worker.rb index e1de6d0046b..128d7b6aa06 100644 --- a/app/workers/namespaces/onboarding_pipeline_created_worker.rb +++ b/app/workers/namespaces/onboarding_pipeline_created_worker.rb @@ -4,7 +4,10 @@ module Namespaces class OnboardingPipelineCreatedWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :subgroups + tags :exclude_from_kubernetes urgency :low deduplicate :until_executing diff --git a/app/workers/namespaces/onboarding_progress_worker.rb b/app/workers/namespaces/onboarding_progress_worker.rb index 9cb5a23cf31..d4db55a9207 100644 --- a/app/workers/namespaces/onboarding_progress_worker.rb +++ b/app/workers/namespaces/onboarding_progress_worker.rb @@ -4,7 +4,10 @@ module Namespaces class OnboardingProgressWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :product_analytics + tags :exclude_from_kubernetes urgency :low deduplicate :until_executed diff --git a/app/workers/namespaces/onboarding_user_added_worker.rb b/app/workers/namespaces/onboarding_user_added_worker.rb index 02608268d6f..8c85dfafa12 100644 --- a/app/workers/namespaces/onboarding_user_added_worker.rb +++ b/app/workers/namespaces/onboarding_user_added_worker.rb @@ -4,7 +4,10 @@ module Namespaces class OnboardingUserAddedWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :users + tags :exclude_from_kubernetes urgency :low idempotent! diff --git a/app/workers/namespaces/prune_aggregation_schedules_worker.rb b/app/workers/namespaces/prune_aggregation_schedules_worker.rb index b94c8b7b4ba..0ea27c532ae 100644 --- a/app/workers/namespaces/prune_aggregation_schedules_worker.rb +++ b/app/workers/namespaces/prune_aggregation_schedules_worker.rb @@ -3,6 +3,8 @@ module Namespaces class PruneAggregationSchedulesWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :source_code_management diff --git a/app/workers/namespaces/root_statistics_worker.rb b/app/workers/namespaces/root_statistics_worker.rb index 5d4b510ceb7..92bf2e22020 100644 --- a/app/workers/namespaces/root_statistics_worker.rb +++ b/app/workers/namespaces/root_statistics_worker.rb @@ -4,6 +4,8 @@ module Namespaces class RootStatisticsWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :update_namespace_statistics feature_category :source_code_management idempotent! diff --git a/app/workers/namespaces/schedule_aggregation_worker.rb b/app/workers/namespaces/schedule_aggregation_worker.rb index cbf5ed44572..cee273688e9 100644 --- a/app/workers/namespaces/schedule_aggregation_worker.rb +++ b/app/workers/namespaces/schedule_aggregation_worker.rb @@ -4,6 +4,8 @@ module Namespaces class ScheduleAggregationWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :update_namespace_statistics feature_category :source_code_management idempotent! diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb index c08f4b4cd75..a579b828354 100644 --- a/app/workers/new_issue_worker.rb +++ b/app/workers/new_issue_worker.rb @@ -2,6 +2,8 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include NewIssuable feature_category :issue_tracking @@ -18,7 +20,7 @@ class NewIssueWorker # rubocop:disable Scalability/IdempotentWorker issuable.create_cross_references!(user) Issues::AfterCreateService - .new(issuable.project, user) + .new(project: issuable.project, current_user: user) .execute(issuable) end diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index 2d28561488b..574c73ad3b5 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -2,6 +2,8 @@ class NewMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include NewIssuable feature_category :code_review @@ -13,7 +15,7 @@ class NewMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker return unless objects_found?(merge_request_id, user_id) MergeRequests::AfterCreateService - .new(issuable.target_project, user) + .new(project: issuable.target_project, current_user: user) .execute(issuable) end diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index 2bb2d0db55c..566bb9a9057 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -3,6 +3,8 @@ class NewNoteWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :issue_tracking urgency :high worker_resource_boundary :cpu diff --git a/app/workers/object_pool/create_worker.rb b/app/workers/object_pool/create_worker.rb index cf87ad95077..586b81fcd30 100644 --- a/app/workers/object_pool/create_worker.rb +++ b/app/workers/object_pool/create_worker.rb @@ -3,6 +3,8 @@ module ObjectPool class CreateWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ObjectPoolQueue include ExclusiveLeaseGuard @@ -28,7 +30,7 @@ module ObjectPool pool.create_object_pool pool.mark_ready - rescue => e + rescue StandardError => e pool.mark_failed raise e end diff --git a/app/workers/object_pool/destroy_worker.rb b/app/workers/object_pool/destroy_worker.rb index d42cee59d03..297780b20bd 100644 --- a/app/workers/object_pool/destroy_worker.rb +++ b/app/workers/object_pool/destroy_worker.rb @@ -3,6 +3,8 @@ module ObjectPool class DestroyWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ObjectPoolQueue def perform(pool_repository_id) diff --git a/app/workers/object_pool/join_worker.rb b/app/workers/object_pool/join_worker.rb index 8103c04b507..282a8f54695 100644 --- a/app/workers/object_pool/join_worker.rb +++ b/app/workers/object_pool/join_worker.rb @@ -3,6 +3,8 @@ module ObjectPool class JoinWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ObjectPoolQueue worker_resource_boundary :cpu diff --git a/app/workers/object_pool/schedule_join_worker.rb b/app/workers/object_pool/schedule_join_worker.rb index c00bb2967f2..44208208d04 100644 --- a/app/workers/object_pool/schedule_join_worker.rb +++ b/app/workers/object_pool/schedule_join_worker.rb @@ -3,6 +3,8 @@ module ObjectPool class ScheduleJoinWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ObjectPoolQueue def perform(pool_id) diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb index 666bacb0188..7323ab50370 100644 --- a/app/workers/object_storage/migrate_uploads_worker.rb +++ b/app/workers/object_storage/migrate_uploads_worker.rb @@ -4,6 +4,8 @@ module ObjectStorage class MigrateUploadsWorker include ApplicationWorker + + sidekiq_options retry: 3 include ObjectStorageQueue feature_category_not_owned! @@ -48,7 +50,7 @@ module ObjectStorage Gitlab::AppLogger.info header(success, failures) Gitlab::AppLogger.warn failures(failures) - raise MigrationFailures.new(failures.map(&:error)) if failures.any? + raise MigrationFailures, failures.map(&:error) if failures.any? end def header(success, failures) @@ -132,7 +134,7 @@ module ObjectStorage def process_uploader(uploader) MigrationResult.new(uploader.upload).tap do |result| uploader.migrate!(@to_store) - rescue => e + rescue StandardError => e result.error = e end end diff --git a/app/workers/packages/composer/cache_cleanup_worker.rb b/app/workers/packages/composer/cache_cleanup_worker.rb index 638e50e18c4..1d47ef87962 100644 --- a/app/workers/packages/composer/cache_cleanup_worker.rb +++ b/app/workers/packages/composer/cache_cleanup_worker.rb @@ -4,9 +4,12 @@ module Packages module Composer class CacheCleanupWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :package_registry + tags :exclude_from_kubernetes idempotent! @@ -22,7 +25,7 @@ module Packages rescue ActiveRecord::RecordNotFound # ignore. likely due to object already being deleted. end - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.log_exception(e) end end diff --git a/app/workers/packages/composer/cache_update_worker.rb b/app/workers/packages/composer/cache_update_worker.rb index 664fb23284f..d87abf2e256 100644 --- a/app/workers/packages/composer/cache_update_worker.rb +++ b/app/workers/packages/composer/cache_update_worker.rb @@ -5,7 +5,10 @@ module Packages class CacheUpdateWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :package_registry + tags :exclude_from_kubernetes idempotent! @@ -15,7 +18,7 @@ module Packages return unless project Gitlab::Composer::Cache.new(project: project, name: package_name, last_page_sha: last_page_sha).execute - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.log_exception(e, project_id: project_id) end end diff --git a/app/workers/packages/debian/process_changes_worker.rb b/app/workers/packages/debian/process_changes_worker.rb new file mode 100644 index 00000000000..edc366a7597 --- /dev/null +++ b/app/workers/packages/debian/process_changes_worker.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Packages + module Debian + class ProcessChangesWorker + include ApplicationWorker + include Gitlab::Utils::StrongMemoize + + deduplicate :until_executed + idempotent! + + queue_namespace :package_repositories + feature_category :package_registry + tags :exclude_from_kubernetes + + def perform(package_file_id, user_id) + @package_file_id = package_file_id + @user_id = user_id + + return unless package_file && user + + ::Packages::Debian::ProcessChangesService.new(package_file, user).execute + rescue ArgumentError, + Packages::Debian::ExtractChangesMetadataService::ExtractionError, + Packages::Debian::ExtractDebMetadataService::CommandFailedError, + Packages::Debian::ExtractMetadataService::ExtractionError, + Packages::Debian::ParseDebian822Service::InvalidDebian822Error, + ActiveRecord::RecordNotFound => e + Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, user_id: @user_id) + package_file.destroy! + end + + private + + attr_reader :package_file_id, :user_id + + def package_file + strong_memoize(:package_file) do + ::Packages::PackageFile.find_by_id(package_file_id) + end + end + + def user + strong_memoize(:user) do + ::User.find_by_id(user_id) + end + end + end + end +end diff --git a/app/workers/packages/go/sync_packages_worker.rb b/app/workers/packages/go/sync_packages_worker.rb index e41f27f2252..c5f631c47db 100644 --- a/app/workers/packages/go/sync_packages_worker.rb +++ b/app/workers/packages/go/sync_packages_worker.rb @@ -4,10 +4,13 @@ module Packages module Go class SyncPackagesWorker include ApplicationWorker + + sidekiq_options retry: 3 include Gitlab::Golang queue_namespace :package_repositories feature_category :package_registry + tags :exclude_from_kubernetes deduplicate :until_executing idempotent! diff --git a/app/workers/packages/maven/metadata/sync_worker.rb b/app/workers/packages/maven/metadata/sync_worker.rb index eb7abf4cdd0..c53117a08c5 100644 --- a/app/workers/packages/maven/metadata/sync_worker.rb +++ b/app/workers/packages/maven/metadata/sync_worker.rb @@ -5,10 +5,13 @@ module Packages module Metadata class SyncWorker include ApplicationWorker + + sidekiq_options retry: 3 include Gitlab::Utils::StrongMemoize queue_namespace :package_repositories feature_category :package_registry + tags :exclude_from_kubernetes deduplicate :until_executing idempotent! @@ -30,10 +33,10 @@ module Packages if result.success? log_extra_metadata_on_done(:message, result.message) else - raise SyncError.new(result.message) + raise SyncError, result.message end - raise SyncError.new(result.message) unless result.success? + raise SyncError, result.message unless result.success? end private diff --git a/app/workers/packages/nuget/extraction_worker.rb b/app/workers/packages/nuget/extraction_worker.rb index 820304a9f3b..4128b229ebe 100644 --- a/app/workers/packages/nuget/extraction_worker.rb +++ b/app/workers/packages/nuget/extraction_worker.rb @@ -5,6 +5,8 @@ module Packages class ExtractionWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :package_repositories feature_category :package_registry @@ -15,10 +17,9 @@ module Packages ::Packages::Nuget::UpdatePackageFromMetadataService.new(package_file).execute - rescue ::Packages::Nuget::MetadataExtractionService::ExtractionError, - ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError => e + rescue StandardError => e Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id) - package_file.package.destroy! + package_file.package.update_column(:status, :error) end end end diff --git a/app/workers/packages/rubygems/extraction_worker.rb b/app/workers/packages/rubygems/extraction_worker.rb index 1e5cd0b54ce..fc32654a2c1 100644 --- a/app/workers/packages/rubygems/extraction_worker.rb +++ b/app/workers/packages/rubygems/extraction_worker.rb @@ -5,12 +5,13 @@ module Packages class ExtractionWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + queue_namespace :package_repositories feature_category :package_registry + tags :exclude_from_kubernetes deduplicate :until_executing - idempotent! - def perform(package_file_id) package_file = ::Packages::PackageFile.find_by_id(package_file_id) @@ -18,9 +19,9 @@ module Packages ::Packages::Rubygems::ProcessGemService.new(package_file).execute - rescue ::Packages::Rubygems::ProcessGemService::ExtractionError => e + rescue StandardError => e Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id) - package_file.package.destroy! + package_file.package.update_column(:status, :error) end end end diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb index cb24441d2f7..cc720676214 100644 --- a/app/workers/pages_domain_removal_cron_worker.rb +++ b/app/workers/pages_domain_removal_cron_worker.rb @@ -2,6 +2,8 @@ class PagesDomainRemovalCronWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue feature_category :pages @@ -10,7 +12,7 @@ class PagesDomainRemovalCronWorker # rubocop:disable Scalability/IdempotentWorke def perform PagesDomain.for_removal.with_logging_info.find_each do |domain| with_context(project: domain.project) { domain.destroy! } - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e) end end diff --git a/app/workers/pages_domain_ssl_renewal_cron_worker.rb b/app/workers/pages_domain_ssl_renewal_cron_worker.rb index fe6d516d3cf..c99eed8a8df 100644 --- a/app/workers/pages_domain_ssl_renewal_cron_worker.rb +++ b/app/workers/pages_domain_ssl_renewal_cron_worker.rb @@ -2,6 +2,8 @@ class PagesDomainSslRenewalCronWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue feature_category :pages diff --git a/app/workers/pages_domain_ssl_renewal_worker.rb b/app/workers/pages_domain_ssl_renewal_worker.rb index 125ba343948..2ab41aab795 100644 --- a/app/workers/pages_domain_ssl_renewal_worker.rb +++ b/app/workers/pages_domain_ssl_renewal_worker.rb @@ -3,8 +3,10 @@ class PagesDomainSslRenewalWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :pages - tags :requires_disk_io + tags :requires_disk_io, :exclude_from_kubernetes def perform(domain_id) domain = PagesDomain.find_by_id(domain_id) diff --git a/app/workers/pages_domain_verification_cron_worker.rb b/app/workers/pages_domain_verification_cron_worker.rb index a30f0b981d8..ec63004716a 100644 --- a/app/workers/pages_domain_verification_cron_worker.rb +++ b/app/workers/pages_domain_verification_cron_worker.rb @@ -2,6 +2,8 @@ class PagesDomainVerificationCronWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue feature_category :pages diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb index ff0463481cd..b67b1b4d8ee 100644 --- a/app/workers/pages_domain_verification_worker.rb +++ b/app/workers/pages_domain_verification_worker.rb @@ -3,8 +3,10 @@ class PagesDomainVerificationWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :pages - tags :requires_disk_io + tags :requires_disk_io, :exclude_from_kubernetes # rubocop: disable CodeReuse/ActiveRecord def perform(domain_id) diff --git a/app/workers/pages_remove_worker.rb b/app/workers/pages_remove_worker.rb index 67ea18545a7..3e60df9027a 100644 --- a/app/workers/pages_remove_worker.rb +++ b/app/workers/pages_remove_worker.rb @@ -6,6 +6,7 @@ class PagesRemoveWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 feature_category :pages + tags :exclude_from_kubernetes loggable_arguments 0 def perform(project_id) diff --git a/app/workers/pages_transfer_worker.rb b/app/workers/pages_transfer_worker.rb index 5d395c9e38a..0d80ec28310 100644 --- a/app/workers/pages_transfer_worker.rb +++ b/app/workers/pages_transfer_worker.rb @@ -3,9 +3,12 @@ class PagesTransferWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + TransferFailedError = Class.new(StandardError) feature_category :pages + tags :exclude_from_kubernetes loggable_arguments 0, 1 def perform(method, args) diff --git a/app/workers/pages_update_configuration_worker.rb b/app/workers/pages_update_configuration_worker.rb index ca5016dc782..8bb9f76670b 100644 --- a/app/workers/pages_update_configuration_worker.rb +++ b/app/workers/pages_update_configuration_worker.rb @@ -3,8 +3,11 @@ class PagesUpdateConfigurationWorker include ApplicationWorker + sidekiq_options retry: 3 + idempotent! feature_category :pages + tags :exclude_from_kubernetes def self.perform_async(*args) return unless ::Settings.pages.local_store.enabled diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb index 0c561626f8c..ee394271653 100644 --- a/app/workers/pages_worker.rb +++ b/app/workers/pages_worker.rb @@ -6,7 +6,7 @@ class PagesWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 feature_category :pages loggable_arguments 0, 1 - tags :requires_disk_io + tags :requires_disk_io, :exclude_from_kubernetes def perform(action, *arg) send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/workers/partition_creation_worker.rb b/app/workers/partition_creation_worker.rb index 119ecd28003..2b21741d6c2 100644 --- a/app/workers/partition_creation_worker.rb +++ b/app/workers/partition_creation_worker.rb @@ -2,6 +2,8 @@ class PartitionCreationWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :database diff --git a/app/workers/personal_access_tokens/expired_notification_worker.rb b/app/workers/personal_access_tokens/expired_notification_worker.rb index 2ff64ec51f3..73568960d38 100644 --- a/app/workers/personal_access_tokens/expired_notification_worker.rb +++ b/app/workers/personal_access_tokens/expired_notification_worker.rb @@ -3,9 +3,12 @@ module PersonalAccessTokens class ExpiredNotificationWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue feature_category :authentication_and_authorization + tags :exclude_from_kubernetes def perform(*args) notification_service = NotificationService.new diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb index 7a016c85a64..aaca78e3c63 100644 --- a/app/workers/personal_access_tokens/expiring_worker.rb +++ b/app/workers/personal_access_tokens/expiring_worker.rb @@ -3,6 +3,8 @@ module PersonalAccessTokens class ExpiringWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue feature_category :authentication_and_authorization diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb index b8dd4768cfb..fbb672f52e3 100644 --- a/app/workers/pipeline_hooks_worker.rb +++ b/app/workers/pipeline_hooks_worker.rb @@ -2,6 +2,8 @@ class PipelineHooksWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_hooks diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb index 1eb9b4ce089..fdab10d7dda 100644 --- a/app/workers/pipeline_metrics_worker.rb +++ b/app/workers/pipeline_metrics_worker.rb @@ -2,6 +2,8 @@ class PipelineMetricsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue urgency :high diff --git a/app/workers/pipeline_notification_worker.rb b/app/workers/pipeline_notification_worker.rb index f4b43106bf2..619570dcf41 100644 --- a/app/workers/pipeline_notification_worker.rb +++ b/app/workers/pipeline_notification_worker.rb @@ -2,6 +2,8 @@ class PipelineNotificationWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue urgency :high diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb index f0929b92bd0..dc14789fe73 100644 --- a/app/workers/pipeline_process_worker.rb +++ b/app/workers/pipeline_process_worker.rb @@ -2,12 +2,15 @@ class PipelineProcessWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_processing feature_category :continuous_integration urgency :high loggable_arguments 1 + data_consistency :delayed, feature_flag: :load_balancing_for_pipeline_process_worker # rubocop: disable CodeReuse/ActiveRecord # `_build_ids` is deprecated and will be removed in 14.0 diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index d81b978f9b0..f1248ec9e58 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -2,6 +2,8 @@ class PipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue feature_category :continuous_integration diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb index 7db4ab8fe0b..e8feb4f2db2 100644 --- a/app/workers/pipeline_update_worker.rb +++ b/app/workers/pipeline_update_worker.rb @@ -4,6 +4,8 @@ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/232806 class PipelineUpdateWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_processing diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 313b901c08e..ce985492935 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -2,6 +2,8 @@ class PostReceive # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include Gitlab::Experiment::Dsl feature_category :source_code_management @@ -123,7 +125,7 @@ class PostReceive # rubocop:disable Scalability/IdempotentWorker def after_project_changes_hooks(project, user, refs, changes) experiment(:new_project_readme, actor: user).track_initial_writes(project) - experiment(:empty_repo_upload, project: project).track(:initial_write) if project.empty_repo? + experiment(:empty_repo_upload, project: project).track_initial_write repository_update_hook_data = Gitlab::DataBuilder::Repository.update(project, user, changes, refs) SystemHooksService.new.execute_hooks(repository_update_hook_data, :repository_update_hooks) Gitlab::UsageDataCounters::SourceCodeCounter.count(:pushes) @@ -134,4 +136,4 @@ class PostReceive # rubocop:disable Scalability/IdempotentWorker end end -PostReceive.prepend_if_ee('EE::PostReceive') +PostReceive.prepend_mod_with('PostReceive') diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 3c7af641f16..54ffe8d3b10 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -10,6 +10,8 @@ class ProcessCommitWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management urgency :high weight 3 @@ -51,7 +53,7 @@ class ProcessCommitWorker # therefore we use IssueCollection here and skip the authorization check in # Issues::CloseService#execute. IssueCollection.new(issues).updatable_by_user(user).each do |issue| - Issues::CloseService.new(project, author) + Issues::CloseService.new(project: project, current_user: author) .close_issue(issue, closed_via: commit) end end diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index 8a9c166e5df..d2796cdb697 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -4,6 +4,8 @@ class ProjectCacheWorker include ApplicationWorker + sidekiq_options retry: 3 + LEASE_TIMEOUT = 15.minutes.to_i feature_category :source_code_management @@ -61,4 +63,4 @@ class ProjectCacheWorker end end -ProjectCacheWorker.prepend_if_ee('EE::ProjectCacheWorker') +ProjectCacheWorker.prepend_mod_with('ProjectCacheWorker') diff --git a/app/workers/project_daily_statistics_worker.rb b/app/workers/project_daily_statistics_worker.rb index 2166655115d..7d673ec00d9 100644 --- a/app/workers/project_daily_statistics_worker.rb +++ b/app/workers/project_daily_statistics_worker.rb @@ -4,6 +4,8 @@ class ProjectDailyStatisticsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management def perform(project_id) diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb index 99d51fc5c2e..be11fa65028 100644 --- a/app/workers/project_destroy_worker.rb +++ b/app/workers/project_destroy_worker.rb @@ -2,10 +2,12 @@ class ProjectDestroyWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ExceptionBacktrace feature_category :source_code_management - tags :requires_disk_io + tags :requires_disk_io, :exclude_from_kubernetes def perform(project_id, user_id, params) project = Project.find(project_id) diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb index 84c3a3e52d0..967be3b3e81 100644 --- a/app/workers/project_service_worker.rb +++ b/app/workers/project_service_worker.rb @@ -3,16 +3,18 @@ class ProjectServiceWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + sidekiq_options dead: false feature_category :integrations worker_has_external_dependencies! def perform(hook_id, data) data = data.with_indifferent_access - service = Service.find(hook_id) - service.execute(data) - rescue => error - service_class = service&.class&.name || "Not Found" - logger.error class: self.class.name, service_class: service_class, message: error.message + integration = Integration.find(hook_id) + integration.execute(data) + rescue StandardError => error + integration_class = integration&.class&.name || "Not Found" + logger.error class: self.class.name, service_class: integration_class, message: error.message end end diff --git a/app/workers/projects/git_garbage_collect_worker.rb b/app/workers/projects/git_garbage_collect_worker.rb index 4f908529b34..0d67a8ac30e 100644 --- a/app/workers/projects/git_garbage_collect_worker.rb +++ b/app/workers/projects/git_garbage_collect_worker.rb @@ -5,6 +5,8 @@ module Projects extend ::Gitlab::Utils::Override include GitGarbageCollectMethods + tags :exclude_from_kubernetes + private override :find_resource @@ -24,7 +26,7 @@ module Projects return if Gitlab::Database.read_only? # GitGarbageCollectWorker may be run on a Geo secondary ::Gitlab::Cleanup::OrphanLfsFileReferences.new(resource, dry_run: false, logger: logger).run! - rescue => err + rescue StandardError => err Gitlab::GitLogger.warn(message: "Cleaning up orphan LFS objects files failed", error: err.message) Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err) end diff --git a/app/workers/projects/post_creation_worker.rb b/app/workers/projects/post_creation_worker.rb index 2ca62e582b6..1970f79729f 100644 --- a/app/workers/projects/post_creation_worker.rb +++ b/app/workers/projects/post_creation_worker.rb @@ -4,7 +4,10 @@ module Projects class PostCreationWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management + tags :exclude_from_kubernetes idempotent! def perform(project_id) diff --git a/app/workers/projects/schedule_bulk_repository_shard_moves_worker.rb b/app/workers/projects/schedule_bulk_repository_shard_moves_worker.rb index 3841ae9b922..55530bff7c1 100644 --- a/app/workers/projects/schedule_bulk_repository_shard_moves_worker.rb +++ b/app/workers/projects/schedule_bulk_repository_shard_moves_worker.rb @@ -4,6 +4,8 @@ module Projects class ScheduleBulkRepositoryShardMovesWorker include ApplicationWorker + sidekiq_options retry: 3 + idempotent! feature_category :gitaly urgency :throttled diff --git a/app/workers/projects/update_repository_storage_worker.rb b/app/workers/projects/update_repository_storage_worker.rb index f4c44458446..1de1c95e043 100644 --- a/app/workers/projects/update_repository_storage_worker.rb +++ b/app/workers/projects/update_repository_storage_worker.rb @@ -5,6 +5,8 @@ module Projects extend ::Gitlab::Utils::Override include ::UpdateRepositoryStorageWorker + sidekiq_options retry: 3 + private override :find_repository_storage_move diff --git a/app/workers/prometheus/create_default_alerts_worker.rb b/app/workers/prometheus/create_default_alerts_worker.rb index 2c4fefa9ece..0dba752ced1 100644 --- a/app/workers/prometheus/create_default_alerts_worker.rb +++ b/app/workers/prometheus/create_default_alerts_worker.rb @@ -4,6 +4,8 @@ module Prometheus class CreateDefaultAlertsWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :incident_management urgency :high idempotent! diff --git a/app/workers/propagate_integration_group_worker.rb b/app/workers/propagate_integration_group_worker.rb index 01155753877..6881740461f 100644 --- a/app/workers/propagate_integration_group_worker.rb +++ b/app/workers/propagate_integration_group_worker.rb @@ -3,15 +3,18 @@ class PropagateIntegrationGroupWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :integrations + tags :exclude_from_kubernetes idempotent! # rubocop: disable CodeReuse/ActiveRecord def perform(integration_id, min_id, max_id) - integration = Service.find_by_id(integration_id) + integration = Integration.find_by_id(integration_id) return unless integration - batch = if integration.instance? + batch = if integration.instance_level? Group.where(id: min_id..max_id).without_integration(integration) else integration.group.descendants.where(id: min_id..max_id).without_integration(integration) diff --git a/app/workers/propagate_integration_inherit_descendant_worker.rb b/app/workers/propagate_integration_inherit_descendant_worker.rb index d589619818c..9067af12de3 100644 --- a/app/workers/propagate_integration_inherit_descendant_worker.rb +++ b/app/workers/propagate_integration_inherit_descendant_worker.rb @@ -3,15 +3,18 @@ class PropagateIntegrationInheritDescendantWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :integrations + tags :exclude_from_kubernetes idempotent! # rubocop: disable CodeReuse/ActiveRecord def perform(integration_id, min_id, max_id) - integration = Service.find_by_id(integration_id) + integration = Integration.find_by_id(integration_id) return unless integration - batch = Service.inherited_descendants_from_self_or_ancestors_from(integration).where(id: min_id..max_id) + batch = Integration.inherited_descendants_from_self_or_ancestors_from(integration).where(id: min_id..max_id) BulkUpdateIntegrationService.new(integration, batch).execute end diff --git a/app/workers/propagate_integration_inherit_worker.rb b/app/workers/propagate_integration_inherit_worker.rb index 40d67c6d3bf..e7649d6714f 100644 --- a/app/workers/propagate_integration_inherit_worker.rb +++ b/app/workers/propagate_integration_inherit_worker.rb @@ -3,15 +3,18 @@ class PropagateIntegrationInheritWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :integrations + tags :exclude_from_kubernetes idempotent! # rubocop: disable CodeReuse/ActiveRecord def perform(integration_id, min_id, max_id) - integration = Service.find_by_id(integration_id) + integration = Integration.find_by_id(integration_id) return unless integration - batch = Service.where(id: min_id..max_id).by_type(integration.type).inherit_from_id(integration.id) + batch = Integration.where(id: min_id..max_id).by_type(integration.type).inherit_from_id(integration.id) BulkUpdateIntegrationService.new(integration, batch).execute end diff --git a/app/workers/propagate_integration_project_worker.rb b/app/workers/propagate_integration_project_worker.rb index 188d81e5fc1..90cf27c4176 100644 --- a/app/workers/propagate_integration_project_worker.rb +++ b/app/workers/propagate_integration_project_worker.rb @@ -3,16 +3,19 @@ class PropagateIntegrationProjectWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :integrations + tags :exclude_from_kubernetes idempotent! # rubocop: disable CodeReuse/ActiveRecord def perform(integration_id, min_id, max_id) - integration = Service.find_by_id(integration_id) + integration = Integration.find_by_id(integration_id) return unless integration batch = Project.where(id: min_id..max_id).without_integration(integration) - batch = batch.in_namespace(integration.group.self_and_descendants) if integration.group_id + batch = batch.in_namespace(integration.group.self_and_descendants) if integration.group_level? return if batch.empty? diff --git a/app/workers/propagate_integration_worker.rb b/app/workers/propagate_integration_worker.rb index bb954b12a25..5e694529bc0 100644 --- a/app/workers/propagate_integration_worker.rb +++ b/app/workers/propagate_integration_worker.rb @@ -3,6 +3,8 @@ class PropagateIntegrationWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :integrations idempotent! loggable_arguments 1 @@ -10,6 +12,6 @@ class PropagateIntegrationWorker # TODO: Keep overwrite parameter for backwards compatibility. Remove after >= 14.0 # https://gitlab.com/gitlab-org/gitlab/-/issues/255382 def perform(integration_id, overwrite = nil) - Admin::PropagateIntegrationService.propagate(Service.find(integration_id)) + Admin::PropagateIntegrationService.propagate(Integration.find(integration_id)) end end diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb index b02525b5106..149577b15cd 100644 --- a/app/workers/propagate_service_template_worker.rb +++ b/app/workers/propagate_service_template_worker.rb @@ -4,6 +4,8 @@ class PropagateServiceTemplateWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :integrations LEASE_TIMEOUT = 4.hours.to_i @@ -12,7 +14,7 @@ class PropagateServiceTemplateWorker # rubocop:disable Scalability/IdempotentWor def perform(template_id) return unless try_obtain_lease_for(template_id) - Admin::PropagateServiceTemplate.propagate(Service.find_by(id: template_id)) + Admin::PropagateServiceTemplate.propagate(Integration.find_by(id: template_id)) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/prune_old_events_worker.rb b/app/workers/prune_old_events_worker.rb index 330de4c7cba..59d324bc573 100644 --- a/app/workers/prune_old_events_worker.rb +++ b/app/workers/prune_old_events_worker.rb @@ -2,6 +2,8 @@ class PruneOldEventsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/prune_web_hook_logs_worker.rb b/app/workers/prune_web_hook_logs_worker.rb index a8e81a24ecd..abfaabbf01d 100644 --- a/app/workers/prune_web_hook_logs_worker.rb +++ b/app/workers/prune_web_hook_logs_worker.rb @@ -4,6 +4,8 @@ # table. class PruneWebHookLogsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/purge_dependency_proxy_cache_worker.rb b/app/workers/purge_dependency_proxy_cache_worker.rb index b4c88592543..9f1ea8a6eb4 100644 --- a/app/workers/purge_dependency_proxy_cache_worker.rb +++ b/app/workers/purge_dependency_proxy_cache_worker.rb @@ -2,6 +2,8 @@ class PurgeDependencyProxyCacheWorker include ApplicationWorker + + sidekiq_options retry: 3 include Gitlab::Allowable idempotent! diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb index ee9ae827bb6..664905eb9e5 100644 --- a/app/workers/rebase_worker.rb +++ b/app/workers/rebase_worker.rb @@ -5,6 +5,8 @@ class RebaseWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management weight 2 loggable_arguments 2 @@ -14,7 +16,7 @@ class RebaseWorker # rubocop:disable Scalability/IdempotentWorker merge_request = MergeRequest.find(merge_request_id) MergeRequests::RebaseService - .new(merge_request.source_project, current_user) + .new(project: merge_request.source_project, current_user: current_user) .execute(merge_request, skip_ci: skip_ci) end end diff --git a/app/workers/releases/create_evidence_worker.rb b/app/workers/releases/create_evidence_worker.rb index d22329216f9..bd790e8d0ee 100644 --- a/app/workers/releases/create_evidence_worker.rb +++ b/app/workers/releases/create_evidence_worker.rb @@ -4,7 +4,10 @@ module Releases class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :release_evidence + tags :exclude_from_kubernetes # pipeline_id is optional for backward compatibility with existing jobs # caller should always try to provide the pipeline and pass nil only diff --git a/app/workers/releases/manage_evidence_worker.rb b/app/workers/releases/manage_evidence_worker.rb index 8a925d22cea..88b6c4aea06 100644 --- a/app/workers/releases/manage_evidence_worker.rb +++ b/app/workers/releases/manage_evidence_worker.rb @@ -3,9 +3,12 @@ module Releases class ManageEvidenceWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :release_evidence + tags :exclude_from_kubernetes def perform releases = Release.without_evidence.released_within_2hrs diff --git a/app/workers/remote_mirror_notification_worker.rb b/app/workers/remote_mirror_notification_worker.rb index 33f5002014d..39a7c0fc79d 100644 --- a/app/workers/remote_mirror_notification_worker.rb +++ b/app/workers/remote_mirror_notification_worker.rb @@ -3,6 +3,8 @@ class RemoteMirrorNotificationWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management weight 2 diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb index 3f1a484f384..edf3a02cff5 100644 --- a/app/workers/remove_expired_group_links_worker.rb +++ b/app/workers/remove_expired_group_links_worker.rb @@ -2,6 +2,8 @@ class RemoveExpiredGroupLinksWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :authentication_and_authorization diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb index fc2ec047e1c..9940953207e 100644 --- a/app/workers/remove_expired_members_worker.rb +++ b/app/workers/remove_expired_members_worker.rb @@ -2,6 +2,8 @@ class RemoveExpiredMembersWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue feature_category :authentication_and_authorization @@ -26,7 +28,7 @@ class RemoveExpiredMembersWorker # rubocop:disable Scalability/IdempotentWorker Users::DestroyService.new(nil).execute(expired_user, skip_authorization: true) end end - rescue => ex + rescue StandardError => ex logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}") end end diff --git a/app/workers/remove_unaccepted_member_invites_worker.rb b/app/workers/remove_unaccepted_member_invites_worker.rb index 4b75b43791e..c1f8e3881f1 100644 --- a/app/workers/remove_unaccepted_member_invites_worker.rb +++ b/app/workers/remove_unaccepted_member_invites_worker.rb @@ -2,9 +2,12 @@ class RemoveUnacceptedMemberInvitesWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :authentication_and_authorization + tags :exclude_from_kubernetes urgency :low idempotent! diff --git a/app/workers/remove_unreferenced_lfs_objects_worker.rb b/app/workers/remove_unreferenced_lfs_objects_worker.rb index 76ab23ebbd5..b42883549ca 100644 --- a/app/workers/remove_unreferenced_lfs_objects_worker.rb +++ b/app/workers/remove_unreferenced_lfs_objects_worker.rb @@ -2,6 +2,8 @@ class RemoveUnreferencedLfsObjectsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/repository_archive_cache_worker.rb b/app/workers/repository_archive_cache_worker.rb index 84f61a60953..84cafba17cf 100644 --- a/app/workers/repository_archive_cache_worker.rb +++ b/app/workers/repository_archive_cache_worker.rb @@ -2,6 +2,8 @@ class RepositoryArchiveCacheWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb index d47f738ccb0..a8744638d7b 100644 --- a/app/workers/repository_check/batch_worker.rb +++ b/app/workers/repository_check/batch_worker.rb @@ -3,6 +3,8 @@ module RepositoryCheck class BatchWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include RepositoryCheckQueue include ExclusiveLeaseGuard @@ -95,4 +97,4 @@ module RepositoryCheck end end -RepositoryCheck::BatchWorker.prepend_if_ee('::EE::RepositoryCheck::BatchWorker') +RepositoryCheck::BatchWorker.prepend_mod_with('RepositoryCheck::BatchWorker') diff --git a/app/workers/repository_check/clear_worker.rb b/app/workers/repository_check/clear_worker.rb index 1689b9bf251..bc19b42da1a 100644 --- a/app/workers/repository_check/clear_worker.rb +++ b/app/workers/repository_check/clear_worker.rb @@ -3,6 +3,8 @@ module RepositoryCheck class ClearWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include RepositoryCheckQueue # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/workers/repository_check/dispatch_worker.rb b/app/workers/repository_check/dispatch_worker.rb index d7a145011fa..30734926765 100644 --- a/app/workers/repository_check/dispatch_worker.rb +++ b/app/workers/repository_check/dispatch_worker.rb @@ -3,6 +3,8 @@ module RepositoryCheck class DispatchWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb index d757b87c23a..a9a8201205e 100644 --- a/app/workers/repository_check/single_repository_worker.rb +++ b/app/workers/repository_check/single_repository_worker.rb @@ -3,6 +3,8 @@ module RepositoryCheck class SingleRepositoryWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include RepositoryCheckQueue def perform(project_id) @@ -67,4 +69,4 @@ module RepositoryCheck end end -RepositoryCheck::SingleRepositoryWorker.prepend_if_ee('::EE::RepositoryCheck::SingleRepositoryWorker') +RepositoryCheck::SingleRepositoryWorker.prepend_mod_with('RepositoryCheck::SingleRepositoryWorker') diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index fc7999e7837..06a6f5b0600 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -2,6 +2,8 @@ class RepositoryForkWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ProjectStartImport include ProjectImportOptions diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index 90764d7374d..0f86d55df22 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -63,4 +63,4 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker end end -RepositoryImportWorker.prepend_if_ee('EE::RepositoryImportWorker') +RepositoryImportWorker.prepend_mod_with('RepositoryImportWorker') diff --git a/app/workers/repository_remove_remote_worker.rb b/app/workers/repository_remove_remote_worker.rb index 5e632b1b1ca..48158cda857 100644 --- a/app/workers/repository_remove_remote_worker.rb +++ b/app/workers/repository_remove_remote_worker.rb @@ -2,6 +2,8 @@ class RepositoryRemoveRemoteWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ExclusiveLeaseGuard feature_category :source_code_management diff --git a/app/workers/requests_profiles_worker.rb b/app/workers/requests_profiles_worker.rb index 106f04d9409..35c18177a81 100644 --- a/app/workers/requests_profiles_worker.rb +++ b/app/workers/requests_profiles_worker.rb @@ -2,6 +2,8 @@ class RequestsProfilesWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/run_pipeline_schedule_worker.rb b/app/workers/run_pipeline_schedule_worker.rb index 605dd624260..553153848c7 100644 --- a/app/workers/run_pipeline_schedule_worker.rb +++ b/app/workers/run_pipeline_schedule_worker.rb @@ -2,6 +2,8 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_creation @@ -25,7 +27,7 @@ class RunPipelineScheduleWorker # rubocop:disable Scalability/IdempotentWorker .execute!(:schedule, ignore_skip_ci: true, save_on_errors: false, schedule: schedule) rescue Ci::CreatePipelineService::CreateError # no-op. This is a user operation error such as corrupted .gitlab-ci.yml. - rescue => e + rescue StandardError => e error(schedule, e) end diff --git a/app/workers/schedule_merge_request_cleanup_refs_worker.rb b/app/workers/schedule_merge_request_cleanup_refs_worker.rb index 967032f99e5..b5ea5298879 100644 --- a/app/workers/schedule_merge_request_cleanup_refs_worker.rb +++ b/app/workers/schedule_merge_request_cleanup_refs_worker.rb @@ -2,9 +2,12 @@ class ScheduleMergeRequestCleanupRefsWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :code_review + tags :exclude_from_kubernetes idempotent! # Based on existing data, MergeRequestCleanupRefsWorker can run 3 jobs per diff --git a/app/workers/schedule_migrate_external_diffs_worker.rb b/app/workers/schedule_migrate_external_diffs_worker.rb index 70e4d56562b..ecafe8f5e7d 100644 --- a/app/workers/schedule_migrate_external_diffs_worker.rb +++ b/app/workers/schedule_migrate_external_diffs_worker.rb @@ -2,6 +2,8 @@ class ScheduleMigrateExternalDiffsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext: # This schedules the `MigrateExternalDiffsWorker` # issue for adding context: https://gitlab.com/gitlab-org/gitlab/issues/202100 diff --git a/app/workers/self_monitoring_project_create_worker.rb b/app/workers/self_monitoring_project_create_worker.rb index 8177efb1683..9dc3bb855fb 100644 --- a/app/workers/self_monitoring_project_create_worker.rb +++ b/app/workers/self_monitoring_project_create_worker.rb @@ -2,6 +2,8 @@ class SelfMonitoringProjectCreateWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ExclusiveLeaseGuard include SelfMonitoringProjectWorker diff --git a/app/workers/self_monitoring_project_delete_worker.rb b/app/workers/self_monitoring_project_delete_worker.rb index 4fa05d71de5..c155c57dec7 100644 --- a/app/workers/self_monitoring_project_delete_worker.rb +++ b/app/workers/self_monitoring_project_delete_worker.rb @@ -2,6 +2,8 @@ class SelfMonitoringProjectDeleteWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ExclusiveLeaseGuard include SelfMonitoringProjectWorker diff --git a/app/workers/service_desk_email_receiver_worker.rb b/app/workers/service_desk_email_receiver_worker.rb index 8649034445c..cfe63e059bb 100644 --- a/app/workers/service_desk_email_receiver_worker.rb +++ b/app/workers/service_desk_email_receiver_worker.rb @@ -3,13 +3,14 @@ class ServiceDeskEmailReceiverWorker < EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - def perform(raw) - return unless ::Gitlab::ServiceDeskEmail.enabled? - - begin - Gitlab::Email::ServiceDeskReceiver.new(raw).execute - rescue => e - handle_failure(raw, e) - end + feature_category :service_desk + sidekiq_options retry: 3 + + def should_perform? + ::Gitlab::ServiceDeskEmail.enabled? + end + + def receiver + @receiver ||= Gitlab::Email::ServiceDeskReceiver.new(raw) end end diff --git a/app/workers/snippets/schedule_bulk_repository_shard_moves_worker.rb b/app/workers/snippets/schedule_bulk_repository_shard_moves_worker.rb index ec3d9dbdf97..88b060a454a 100644 --- a/app/workers/snippets/schedule_bulk_repository_shard_moves_worker.rb +++ b/app/workers/snippets/schedule_bulk_repository_shard_moves_worker.rb @@ -4,6 +4,8 @@ module Snippets class ScheduleBulkRepositoryShardMovesWorker include ApplicationWorker + sidekiq_options retry: 3 + idempotent! feature_category :gitaly urgency :throttled diff --git a/app/workers/snippets/update_repository_storage_worker.rb b/app/workers/snippets/update_repository_storage_worker.rb index 83b655e9986..ffb01e2623b 100644 --- a/app/workers/snippets/update_repository_storage_worker.rb +++ b/app/workers/snippets/update_repository_storage_worker.rb @@ -5,6 +5,8 @@ module Snippets extend ::Gitlab::Utils::Override include ::UpdateRepositoryStorageWorker + sidekiq_options retry: 3 + private override :find_repository_storage_move diff --git a/app/workers/ssh_keys/expired_notification_worker.rb b/app/workers/ssh_keys/expired_notification_worker.rb index ab6d1998773..9d5143fe655 100644 --- a/app/workers/ssh_keys/expired_notification_worker.rb +++ b/app/workers/ssh_keys/expired_notification_worker.rb @@ -3,15 +3,19 @@ module SshKeys class ExpiredNotificationWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue feature_category :compliance_management + tags :exclude_from_kubernetes idempotent! def perform return unless ::Feature.enabled?(:ssh_key_expiration_email_notification, default_enabled: :yaml) - User.with_ssh_key_expired_today.find_each do |user| + # rubocop:disable CodeReuse/ActiveRecord + User.with_ssh_key_expired_today.find_each(batch_size: 10_000) do |user| with_context(user: user) do Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expired ssh key(s)" @@ -19,6 +23,7 @@ module SshKeys Keys::ExpiryNotificationService.new(user, { keys: keys, expiring_soon: false }).execute end + # rubocop:enable CodeReuse/ActiveRecord end end end diff --git a/app/workers/ssh_keys/expiring_soon_notification_worker.rb b/app/workers/ssh_keys/expiring_soon_notification_worker.rb index 3214cd7a242..1ec655b5cf5 100644 --- a/app/workers/ssh_keys/expiring_soon_notification_worker.rb +++ b/app/workers/ssh_keys/expiring_soon_notification_worker.rb @@ -3,15 +3,19 @@ module SshKeys class ExpiringSoonNotificationWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue feature_category :compliance_management + tags :exclude_from_kubernetes idempotent! def perform return unless ::Feature.enabled?(:ssh_key_expiration_email_notification, default_enabled: :yaml) - User.with_ssh_key_expiring_soon.find_each do |user| + # rubocop:disable CodeReuse/ActiveRecord + User.with_ssh_key_expiring_soon.find_each(batch_size: 10_000) do |user| with_context(user: user) do Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about expiring soon ssh key(s)" @@ -20,6 +24,7 @@ module SshKeys Keys::ExpiryNotificationService.new(user, { keys: keys, expiring_soon: true }).execute end end + # rubocop:enable CodeReuse/ActiveRecord end end end diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb index 20db19536c3..e206a51a417 100644 --- a/app/workers/stage_update_worker.rb +++ b/app/workers/stage_update_worker.rb @@ -2,6 +2,8 @@ class StageUpdateWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_processing diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index bd721df73c6..6b9f90ce1fc 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -2,6 +2,8 @@ class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue feature_category :continuous_integration @@ -73,7 +75,7 @@ class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker Gitlab::OptimisticLocking.retry_lock(build, 3, name: 'stuck_ci_jobs_worker_drop_build') do |b| b.drop(reason) end - rescue => ex + rescue StandardError => ex build.doom! track_exception_for_build(ex, build) diff --git a/app/workers/stuck_export_jobs_worker.rb b/app/workers/stuck_export_jobs_worker.rb index 6d8d60d2fc0..398f2c915a9 100644 --- a/app/workers/stuck_export_jobs_worker.rb +++ b/app/workers/stuck_export_jobs_worker.rb @@ -3,6 +3,8 @@ # rubocop:disable Scalability/IdempotentWorker class StuckExportJobsWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker updates export states inline and does not schedule # other jobs. diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb index bea9d67b3e8..e50b218e1f6 100644 --- a/app/workers/stuck_merge_jobs_worker.rb +++ b/app/workers/stuck_merge_jobs_worker.rb @@ -2,6 +2,8 @@ class StuckMergeJobsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :code_review diff --git a/app/workers/system_hook_push_worker.rb b/app/workers/system_hook_push_worker.rb index ff1f2baf058..8c801f2bed8 100644 --- a/app/workers/system_hook_push_worker.rb +++ b/app/workers/system_hook_push_worker.rb @@ -3,6 +3,8 @@ class SystemHookPushWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management def perform(push_data, hook_id) diff --git a/app/workers/todos_destroyer/confidential_issue_worker.rb b/app/workers/todos_destroyer/confidential_issue_worker.rb index b29d4168162..8a43ea3c2e0 100644 --- a/app/workers/todos_destroyer/confidential_issue_worker.rb +++ b/app/workers/todos_destroyer/confidential_issue_worker.rb @@ -3,6 +3,8 @@ module TodosDestroyer class ConfidentialIssueWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include TodosDestroyerQueue def perform(issue_id = nil, project_id = nil) diff --git a/app/workers/todos_destroyer/destroyed_issuable_worker.rb b/app/workers/todos_destroyer/destroyed_issuable_worker.rb index 6ca1959ff34..a3a8147095e 100644 --- a/app/workers/todos_destroyer/destroyed_issuable_worker.rb +++ b/app/workers/todos_destroyer/destroyed_issuable_worker.rb @@ -3,8 +3,12 @@ module TodosDestroyer class DestroyedIssuableWorker include ApplicationWorker + + sidekiq_options retry: 3 include TodosDestroyerQueue + tags :exclude_from_kubernetes + idempotent! def perform(target_id, target_type) diff --git a/app/workers/todos_destroyer/entity_leave_worker.rb b/app/workers/todos_destroyer/entity_leave_worker.rb index 4996456dc91..166d8701f7a 100644 --- a/app/workers/todos_destroyer/entity_leave_worker.rb +++ b/app/workers/todos_destroyer/entity_leave_worker.rb @@ -3,6 +3,8 @@ module TodosDestroyer class EntityLeaveWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include TodosDestroyerQueue loggable_arguments 2 diff --git a/app/workers/todos_destroyer/group_private_worker.rb b/app/workers/todos_destroyer/group_private_worker.rb index a1943bee2ec..30d1f74fb28 100644 --- a/app/workers/todos_destroyer/group_private_worker.rb +++ b/app/workers/todos_destroyer/group_private_worker.rb @@ -3,6 +3,8 @@ module TodosDestroyer class GroupPrivateWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include TodosDestroyerQueue def perform(group_id) diff --git a/app/workers/todos_destroyer/private_features_worker.rb b/app/workers/todos_destroyer/private_features_worker.rb index 6e55467234a..d6a4260a464 100644 --- a/app/workers/todos_destroyer/private_features_worker.rb +++ b/app/workers/todos_destroyer/private_features_worker.rb @@ -3,6 +3,8 @@ module TodosDestroyer class PrivateFeaturesWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include TodosDestroyerQueue def perform(project_id, user_id = nil) diff --git a/app/workers/todos_destroyer/project_private_worker.rb b/app/workers/todos_destroyer/project_private_worker.rb index 2a06edc666e..c4fed03f11a 100644 --- a/app/workers/todos_destroyer/project_private_worker.rb +++ b/app/workers/todos_destroyer/project_private_worker.rb @@ -3,6 +3,8 @@ module TodosDestroyer class ProjectPrivateWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include TodosDestroyerQueue def perform(project_id) diff --git a/app/workers/trending_projects_worker.rb b/app/workers/trending_projects_worker.rb index 5876cfb1fe7..8322110b753 100644 --- a/app/workers/trending_projects_worker.rb +++ b/app/workers/trending_projects_worker.rb @@ -2,6 +2,8 @@ class TrendingProjectsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :source_code_management diff --git a/app/workers/update_container_registry_info_worker.rb b/app/workers/update_container_registry_info_worker.rb index 14a816f25ef..cf08c650d0d 100644 --- a/app/workers/update_container_registry_info_worker.rb +++ b/app/workers/update_container_registry_info_worker.rb @@ -2,6 +2,8 @@ class UpdateContainerRegistryInfoWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :container_registry diff --git a/app/workers/update_external_pull_requests_worker.rb b/app/workers/update_external_pull_requests_worker.rb index e916331ae82..ee47cbd6523 100644 --- a/app/workers/update_external_pull_requests_worker.rb +++ b/app/workers/update_external_pull_requests_worker.rb @@ -3,6 +3,8 @@ class UpdateExternalPullRequestsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management weight 3 loggable_arguments 2 diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb index 63d11d33283..f1dd250f432 100644 --- a/app/workers/update_head_pipeline_for_merge_request_worker.rb +++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb @@ -2,6 +2,8 @@ class UpdateHeadPipelineForMergeRequestWorker include ApplicationWorker + + sidekiq_options retry: 3 include PipelineQueue queue_namespace :pipeline_processing diff --git a/app/workers/update_highest_role_worker.rb b/app/workers/update_highest_role_worker.rb index 952f1e511ea..cecf3f99b50 100644 --- a/app/workers/update_highest_role_worker.rb +++ b/app/workers/update_highest_role_worker.rb @@ -3,6 +3,8 @@ class UpdateHighestRoleWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :utilization urgency :high weight 2 @@ -15,7 +17,7 @@ class UpdateHighestRoleWorker return unless user.present? - if user.active? && user.user_type.nil? && !user.internal? + if user.active? && user.human? && !user.internal? Users::UpdateHighestMemberRoleService.new(user).execute else UserHighestRole.where(user_id: user_id).delete_all diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb index 46cb32e7f08..6f86a7e7e2f 100644 --- a/app/workers/update_merge_requests_worker.rb +++ b/app/workers/update_merge_requests_worker.rb @@ -3,6 +3,8 @@ class UpdateMergeRequestsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :code_review urgency :high worker_resource_boundary :cpu @@ -17,7 +19,7 @@ class UpdateMergeRequestsWorker # rubocop:disable Scalability/IdempotentWorker user = User.find_by(id: user_id) return unless user - MergeRequests::RefreshService.new(project, user).execute(oldrev, newrev, ref) + MergeRequests::RefreshService.new(project: project, current_user: user).execute(oldrev, newrev, ref) end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/update_project_statistics_worker.rb b/app/workers/update_project_statistics_worker.rb index 336877d9f57..c93c32f4e75 100644 --- a/app/workers/update_project_statistics_worker.rb +++ b/app/workers/update_project_statistics_worker.rb @@ -4,6 +4,8 @@ class UpdateProjectStatisticsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management # project_id - The ID of the project for which to flush the cache. diff --git a/app/workers/upload_checksum_worker.rb b/app/workers/upload_checksum_worker.rb index ce43b56bbd8..765e3a63e75 100644 --- a/app/workers/upload_checksum_worker.rb +++ b/app/workers/upload_checksum_worker.rb @@ -3,6 +3,8 @@ class UploadChecksumWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :geo_replication def perform(upload_id) diff --git a/app/workers/user_status_cleanup/batch_worker.rb b/app/workers/user_status_cleanup/batch_worker.rb index 0c1087cc4d2..f46b4119f9b 100644 --- a/app/workers/user_status_cleanup/batch_worker.rb +++ b/app/workers/user_status_cleanup/batch_worker.rb @@ -4,11 +4,14 @@ module UserStatusCleanup # This worker will run every minute to look for user status records to clean up. class BatchWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext include CronjobQueue # rubocop:enable Scalability/CronWorkerContext feature_category :users + tags :exclude_from_kubernetes idempotent! diff --git a/app/workers/users/create_statistics_worker.rb b/app/workers/users/create_statistics_worker.rb index fb1b192577f..e44039f2016 100644 --- a/app/workers/users/create_statistics_worker.rb +++ b/app/workers/users/create_statistics_worker.rb @@ -3,6 +3,8 @@ module Users class CreateStatisticsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 # rubocop:disable Scalability/CronWorkerContext # This worker does not perform work scoped to a context include CronjobQueue diff --git a/app/workers/users/deactivate_dormant_users_worker.rb b/app/workers/users/deactivate_dormant_users_worker.rb new file mode 100644 index 00000000000..e583823312f --- /dev/null +++ b/app/workers/users/deactivate_dormant_users_worker.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Users + class DeactivateDormantUsersWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + include CronjobQueue + + feature_category :utilization + tags :exclude_from_kubernetes + + NUMBER_OF_BATCHES = 50 + BATCH_SIZE = 200 + PAUSE_SECONDS = 0.25 + + def perform + return if Gitlab.com? + + return unless ::Gitlab::CurrentSettings.current_application_settings.deactivate_dormant_users + + with_context(caller_id: self.class.name.to_s) do + NUMBER_OF_BATCHES.times do + result = User.connection.execute(update_query) + + break if result.cmd_tuples == 0 + + sleep(PAUSE_SECONDS) + end + end + end + + private + + def update_query + <<~SQL + UPDATE "users" + SET "state" = 'deactivated' + WHERE "users"."id" IN ( + (#{users.dormant.to_sql}) + UNION + (#{users.with_no_activity.to_sql}) + LIMIT #{BATCH_SIZE} + ) + SQL + end + + def users + User.select(:id).limit(BATCH_SIZE) + end + end +end diff --git a/app/workers/users/update_open_issue_count_worker.rb b/app/workers/users/update_open_issue_count_worker.rb new file mode 100644 index 00000000000..d9e313d53df --- /dev/null +++ b/app/workers/users/update_open_issue_count_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Users + class UpdateOpenIssueCountWorker + include ApplicationWorker + + feature_category :users + tags :exclude_from_kubernetes + idempotent! + + def perform(target_user_ids) + target_user_ids = Array.wrap(target_user_ids) + + raise ArgumentError, 'No target user ID provided' if target_user_ids.empty? + + target_users = User.id_in(target_user_ids) + raise ArgumentError, 'No valid target user ID provided' if target_users.empty? + + target_users.each do |user| + Users::UpdateAssignedOpenIssueCountService.new(target_user: user).execute + end + rescue StandardError => exception + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception) + end + end +end diff --git a/app/workers/wait_for_cluster_creation_worker.rb b/app/workers/wait_for_cluster_creation_worker.rb index 2e3feb1a4d1..525a72e02ef 100644 --- a/app/workers/wait_for_cluster_creation_worker.rb +++ b/app/workers/wait_for_cluster_creation_worker.rb @@ -2,6 +2,8 @@ class WaitForClusterCreationWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + + sidekiq_options retry: 3 include ClusterQueue worker_has_external_dependencies! diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb index 5230f3bfa1f..dffab61dd0e 100644 --- a/app/workers/web_hook_worker.rb +++ b/app/workers/web_hook_worker.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -class WebHookWorker # rubocop:disable Scalability/IdempotentWorker +# Worker cannot be idempotent: https://gitlab.com/gitlab-org/gitlab/-/issues/218559 +# rubocop:disable Scalability/IdempotentWorker +class WebHookWorker include ApplicationWorker feature_category :integrations @@ -16,3 +18,4 @@ class WebHookWorker # rubocop:disable Scalability/IdempotentWorker WebHookService.new(hook, data, hook_name).execute end end +# rubocop:enable Scalability/IdempotentWorker diff --git a/app/workers/web_hooks/destroy_worker.rb b/app/workers/web_hooks/destroy_worker.rb index 13a5a7bf1e6..c1886576c41 100644 --- a/app/workers/web_hooks/destroy_worker.rb +++ b/app/workers/web_hooks/destroy_worker.rb @@ -4,7 +4,10 @@ module WebHooks class DestroyWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :integrations + tags :exclude_from_kubernetes urgency :low idempotent! diff --git a/app/workers/wikis/git_garbage_collect_worker.rb b/app/workers/wikis/git_garbage_collect_worker.rb index 1b455c50618..f34d3be51d2 100644 --- a/app/workers/wikis/git_garbage_collect_worker.rb +++ b/app/workers/wikis/git_garbage_collect_worker.rb @@ -5,6 +5,8 @@ module Wikis extend ::Gitlab::Utils::Override include GitGarbageCollectMethods + tags :exclude_from_kubernetes + private override :find_resource diff --git a/app/workers/x509_certificate_revoke_worker.rb b/app/workers/x509_certificate_revoke_worker.rb index abd0e5eefa7..cbf9fbb7525 100644 --- a/app/workers/x509_certificate_revoke_worker.rb +++ b/app/workers/x509_certificate_revoke_worker.rb @@ -3,6 +3,8 @@ class X509CertificateRevokeWorker include ApplicationWorker + sidekiq_options retry: 3 + feature_category :source_code_management idempotent! diff --git a/app/workers/x509_issuer_crl_check_worker.rb b/app/workers/x509_issuer_crl_check_worker.rb index 5fc92da803c..d66ad6c1c15 100644 --- a/app/workers/x509_issuer_crl_check_worker.rb +++ b/app/workers/x509_issuer_crl_check_worker.rb @@ -2,6 +2,8 @@ class X509IssuerCrlCheckWorker include ApplicationWorker + + sidekiq_options retry: 3 include CronjobQueue feature_category :source_code_management |