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