diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml new file mode 100644 index 00000000..e7ce9879 --- /dev/null +++ b/.github/workflows/danger.yml @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2022 Serokell +# +# SPDX-License-Identifier: MPL-2.0 + +name: Danger + +on: [pull_request] + +jobs: + run-danger-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '2.7' + bundler-cache: true + - uses: MeilCli/danger-action@v5 + name: Instant checks + with: + install_path: 'vendor/bundle' + danger_file: './danger/instant-checks.rb' + danger_id: 'instant-checks' + env: + DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_BOT_TOKEN }} + - uses: MeilCli/danger-action@v5 + name: Premerge checks + with: + install_path: 'vendor/bundle' + danger_file: './danger/premerge-checks.rb' + danger_id: 'premerge-checks' + env: + DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_BOT_TOKEN }} diff --git a/LICENSES/LicenseRef-MIT-OA.txt b/LICENSES/LicenseRef-MIT-OA.txt new file mode 100644 index 00000000..cf71fba6 --- /dev/null +++ b/LICENSES/LicenseRef-MIT-OA.txt @@ -0,0 +1,21 @@ +MIT License +Copyright (c) 2021-2022 Oxhead Alpha +Copyright (c) 2019-2021 Tocqueville Group + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/danger/branch-name.rb b/danger/branch-name.rb new file mode 100644 index 00000000..f4aed83c --- /dev/null +++ b/danger/branch-name.rb @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2022 Oxhead Alpha +# SPDX-License-Identifier: LicenseRef-MIT-OA + +require_relative 'helpers' + +def check_branch_name + # Proper branch name + if branch_match = githost.branch_for_head.match(/([^\/]+)\/([^\-]+)-(.+)/) + nick, issue_id, desc = branch_match.captures + + # We've decided not to put any restrictions on nickname for now + + unless /^(#\d+|chore)$/.match?(issue_id) + warn( + "Bad issue ID in branch name.\n"\ + "Valid format for issue IDs: `#123` or `chore`." + ) + end + + weird_chars = desc.scan(/[^a-zA-Z\-\d]/) + unless weird_chars.empty? + warn( + "Please, only use letters, digits and dashes in the branch name. + Found: #{weird_chars}" + ) + end + elsif + warn( + "Please use `/-`` format for branch names.`\n"\ + "Example: `lazyman/#123-my-commit`" + ) + end +end diff --git a/danger/commit-style.rb b/danger/commit-style.rb new file mode 100644 index 00000000..836b03d2 --- /dev/null +++ b/danger/commit-style.rb @@ -0,0 +1,93 @@ +# SPDX-FileCopyrightText: 2022 Oxhead Alpha +# SPDX-License-Identifier: LicenseRef-MIT-OA + +require_relative 'helpers' + +def wrap_workarounds(fun) + return lambda { |msg| + method(fun).call(msg + "\nSee also \\[Note\\].") + markdown( + "\\[Note\\]: Skip this check by adding `wip`, `tmp` or `[temporary]` to the commit subject. "\ + "Fixup commits (marked with `fixup!` or `squash!`) are also exempt from this check.") + } +end + +def check_commit_style (mywarn = wrap_workarounds(:warn), myfail = wrap_workarounds(:fail)) + # Proper commit style + # Note: we do not use commit_lint plugin because it triggers on fixup commits + git.commits.each { |commit| + if commit.fixup? || commit.wip? + next + end + + subject = commit.subject + subject_payload = subject.sub(issue_tags_pattern, "") + subject_ticked = commit.subject_ticked + + unless has_valid_issue_tags(subject) + # If any of these substrings is included into commit message, + # we are fine with issue tag absence. + exclusions = [ + # In lower-case + "changelog" + ] + if exclusions.none? { |exc| subject.downcase.include?(exc) } + mywarn.call("In #{commit.sha} message lacks issue id: #{subject_ticked}.") + end + end + + if subject_payload.start_with?(" ") + mywarn.call("Extra space in commit #{commit.sha} subject after the issue tags: #{subject_ticked}.") + elsif !subject_payload.start_with?(/[A-Z]/) + mywarn.call("In #{commit.sha} subject does not begin with an uppercase letter: #{subject_ticked}.") + end + + if subject[-1..-1] == '.' + mywarn.call("In #{commit.sha} message ends with a dot: #{subject_ticked} :fire_engine:") + end + + if subject.length > 90 + myfail.call("Nooo, such long commit message names do not work (#{commit.sha}).") + elsif subject.length > 72 + mywarn.call("In commit #{commit.sha} message is too long (#{subject.length} chars), "\ + "please keep its length within 72 characters.") + end + + if commit.message_body.empty? + # If any of these substrings is included into commit message, + # we are fine with commit description absence. + exclusions = [ + # In lower-case + "changelog" + ] + unless commit.chore? || exclusions.any? { |exc| subject.downcase.include?(exc) } + myfail.call( + "Commit #{commit.sha} lacks description :unamused:\n"\ + "Commits marked as `[Chore]` are exempt from this check." + ) + end + else + # Checks on description + + if !commit.blank_line_after_subject? + mywarn.call("In #{commit.sha} blank line is missing after the commit's subject.") + end + + if !commit.chore? + description_patterns = [ + /^Problem:[ \n].*^Solution:[ \n]/m, + /And yes, I don't care about templates/ + ] + unless description_patterns.any? { |pattern| pattern.match?(commit.description) } + mywarn.call( + "Description of #{commit.sha} does not follow the template.\n"\ + "Try `Problem:`/`Solution:` structure.\n"\ + "If you really have to, you can add `And yes, I don't care about templates` "\ + "to the commit message body." + ) + end + end + end + + } +end diff --git a/danger/helpers.rb b/danger/helpers.rb new file mode 100644 index 00000000..5edc6e31 --- /dev/null +++ b/danger/helpers.rb @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: 2022 Oxhead Alpha +# SPDX-License-Identifier: LicenseRef-MIT-OA + +### Some convenient extensions and helpers ### + +class Danger::Dangerfile + # Unified `github`/`gitlab` variable. + def githost + if defined?(github) + githost = github + elsif defined?(gitlab) + githost = gitlab + else + error "Failed to figure out which service are we running from." + end + end + + # The original PR title (excludes "Draft" tags). + def pr_title_payload + githost.mr_title + .sub(/^(Draft|WIP): /, "") + .sub(/^\[Draft\] /, "") + end + alias_method :mr_title_payload, :pr_title_payload +end + +class Git::Diff::DiffFile + # When a file is renamed (e.g. with `git mv`) 'path' will return the old + # path, this is true even if the file was modified a little. + # However we'd probably like to access the destination path instead, so + # this parses the new path from the 'file.patch'. + def destination_path + rename_match = /(?<=(\nrename to ))(\S)*/.match(self.patch) + if rename_match.nil? + self.path + else + rename_match.to_s + end + end +end + +# Add some helpers to Commit class. +class Git::Object::Commit + # Commit subject (unlike the 'message' field which includes description). + def subject + self.message.lines.first.rstrip + end + alias_method :message_subject, :subject + + def subject_ticked + "`" + self.subject.gsub("`", "'") + "`" + end + + # Commit description. + # If absent, set to empty string. + def description + self.message.lines.drop(1).drop_while{ |s| s == "\n" }.join + end + alias_method :message_body, :description + + # Whether there is a blank line between commit subject and body. + def blank_line_after_subject? + self.message.lines[1] == "\n" + end + + # Whether this commit is fixup commit. + def fixup? + return /\bfixup!|\bsquash!/.match?(subject) + end + + # Whether this commit is a temporary commit. + def wip? + return /\bwip\b|\btmp\b|\[temporary\]/i.match?(subject) + end + + # Whether this commit is a minor chore commit. + # Such commits usually have an obvious purpose and are not related to the + # business logic. + def chore? + return subject.include?("[Chore]") + end + +end + +module Danger::Helpers::CommentsHelper + # By default, every comment for a particular source code also includes + # the name of the referred file. + # + # We don't need this feature. + # The source code welcomes us to override the respective method, and this is + # exactly what we do. + def markdown_link_to_message(_, _) + "" + end +end + +# Example: `[Chore][#123] My commit` +def issue_tags_pattern + /^(\[(#\d+|Chore)\])+ (?=\w)/ +end + +# Whether a string starts with an appropriate ticket tag. +def has_valid_issue_tags(name) + return name.start_with?(issue_tags_pattern) +end diff --git a/danger/instant-checks.rb b/danger/instant-checks.rb new file mode 100644 index 00000000..1b9af2ac --- /dev/null +++ b/danger/instant-checks.rb @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2022 Oxhead Alpha +# SPDX-License-Identifier: LicenseRef-MIT-OA + +# Checks that, when hit, should be fixed as soon as possible. + +require_relative 'helpers' +require_relative 'trailing-whitespaces' +require_relative 'commit-style' +require_relative 'branch-name' +require_relative 'licenses' + +check_trailing_whitespaces() + +# Clean commits history +if git.commits.any? { |c| c.subject =~ /^Merge branch/ } + fail 'Please, no merge commits. Rebase for the win.' +end + +check_commit_style() + +# Proper MR content +mr_title_payload = githost.mr_title_payload + +unless has_valid_issue_tags(mr_title_payload) + warn( + "Inappropriate title for PR.\n"\ + "Should start from issue ID (e.g. `[#123]`) or `[Chore]` tag.\n"\ + "Note: please use `[Chore]` also for tickets tracked internally on YouTrack." + ) +end + +check_branch_name() + +check_licenses() diff --git a/danger/licenses.rb b/danger/licenses.rb new file mode 100644 index 00000000..45727adc --- /dev/null +++ b/danger/licenses.rb @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2022 Oxhead Alpha +# SPDX-License-Identifier: LicenseRef-MIT-OA + +require_relative 'helpers' + +def check_licenses + # Licenses + # Check that the REUSE license header contains the current year. + cur_year = Time.new.year + # Only go over new files; see https://gitlab.com/morley-framework/morley/-/merge_requests/1091 + # for the discussion and rationale for this. + git.added_files.each do |file| + File.foreach(file).with_index(1).find do |line, line_num| + if year_match = line.match(/(^.*SPDX-FileCopyrightText:)\s+(\w+-)?(\w+)\s+(.*)$/) + head, start, year, holder = year_match.captures + unless (year == cur_year.to_s) + markdown( + ":warning: The year in this license header is outdated, time to update!\n\n", + file: file, line: line_num + ) + end + # either way, we return 'true' to stop looking after the first 'match' + true + end + end + end +end diff --git a/danger/premerge-checks.rb b/danger/premerge-checks.rb new file mode 100644 index 00000000..03afc0e5 --- /dev/null +++ b/danger/premerge-checks.rb @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2022 Oxhead Alpha +# SPDX-License-Identifier: LicenseRef-MIT-OA + +# Checks that are fine to fail during development, but must be fixed before merging. + +require_relative 'helpers' + +# Fixup commits +if git.commits.any? &:fixup? + fail "Some fixup commits are still there." +end + +# Work-in-progress commits +if git.commits.any? &:wip? + fail "WIP commits are still there." +end diff --git a/danger/trailing-whitespaces.rb b/danger/trailing-whitespaces.rb new file mode 100644 index 00000000..96e482d0 --- /dev/null +++ b/danger/trailing-whitespaces.rb @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: 2022 Oxhead Alpha +# SPDX-License-Identifier: LicenseRef-MIT-OA + +require_relative 'helpers' + +# Report that there are some issues with trailing whitespaces or newlines. +def report_trailing_whitespaces_violation + # This can be safely called multiple times + # because Danger deduplicates messages. + fail("Trailing whitespaces and/or incorrect end-of-file newlines detected.") +end + +def check_trailing_whitespaces + git.diff.each do |file| + if !["new", "modified"].include?(file.type) || file.binary? + next + end + + path = file.destination_path + contents = File.read(path) + lines = contents.lines + + if contents.empty? + next + end + + lines.each.with_index(1) do |line, line_index| + if line[-1..-1] == "\n" + line = line[0..-2] + end + if /\s$/.match?(line) + report_trailing_whitespaces_violation + markdown( + "I have found some trailing whitespaces here:\n"\ + "```suggestion:-0+0\n"\ + "#{line.rstrip}\n"\ + "```\n", + file: path, line: line_index + ) + end + end + + last_line = lines.last + unless last_line[-1..-1] == "\n" + report_trailing_whitespaces_violation + markdown( + "I have found a missing newline at the end of this file:\n"\ + "```suggestion:-0+0\n"\ + "#{last_line.rstrip}\n\n"\ + "```", + file: path, line: lines.length + ) + end + + trailing_empty_lines = 0 + lines.reverse_each do |line| + if line == "\n" then + trailing_empty_lines = trailing_empty_lines + 1 + else + break + end + end + if trailing_empty_lines == 0 + elsif trailing_empty_lines == 1 + trailing_newline_err_msg = "Extra newline at the end of the file." + elsif trailing_empty_lines <= 3 + trailing_newline_err_msg = "Yay, that's a combo!" + else + pic_url = "https://raw.githubusercontent.com/serokell/resources/ed58049e3724f11cef43d45bf3958878a716fc47/dangerbot/pics/trailing-whitespaces-voilation.jpg" + trailing_newline_err_msg = "![:thinking:](#{pic_url})" + end + if !trailing_newline_err_msg.nil? + report_trailing_whitespaces_violation + markdown( + "#{trailing_newline_err_msg}\n"\ + "```suggestion:-#{trailing_empty_lines - 1}+0\n"\ + "```\n", + file: path, line: lines.length + ) + end + end +end