danger-dangermattic 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/.buildkite/gem-push.sh +15 -0
  3. data/.buildkite/pipeline.yml +69 -0
  4. data/.bundle/config +2 -0
  5. data/.github/workflows/reusable-check-labels-on-issues.yml +91 -0
  6. data/.github/workflows/reusable-run-danger.yml +54 -0
  7. data/.gitignore +30 -0
  8. data/.rubocop.yml +67 -0
  9. data/.ruby-version +1 -0
  10. data/.yardopts +7 -0
  11. data/CHANGELOG.md +7 -0
  12. data/Gemfile +5 -0
  13. data/Gemfile.lock +191 -0
  14. data/Guardfile +21 -0
  15. data/LICENSE +373 -0
  16. data/README.md +68 -0
  17. data/Rakefile +24 -0
  18. data/danger-dangermattic.gemspec +58 -0
  19. data/lib/danger_dangermattic.rb +3 -0
  20. data/lib/danger_plugin.rb +4 -0
  21. data/lib/dangermattic/gem_version.rb +5 -0
  22. data/lib/dangermattic/plugins/android_release_checker.rb +50 -0
  23. data/lib/dangermattic/plugins/android_strings_checker.rb +31 -0
  24. data/lib/dangermattic/plugins/android_unit_test_checker.rb +187 -0
  25. data/lib/dangermattic/plugins/common/common_release_checker.rb +113 -0
  26. data/lib/dangermattic/plugins/common/git_utils.rb +166 -0
  27. data/lib/dangermattic/plugins/common/github_utils.rb +68 -0
  28. data/lib/dangermattic/plugins/common/reporter.rb +38 -0
  29. data/lib/dangermattic/plugins/ios_release_checker.rb +106 -0
  30. data/lib/dangermattic/plugins/labels_checker.rb +74 -0
  31. data/lib/dangermattic/plugins/manifest_pr_checker.rb +106 -0
  32. data/lib/dangermattic/plugins/milestone_checker.rb +98 -0
  33. data/lib/dangermattic/plugins/podfile_checker.rb +122 -0
  34. data/lib/dangermattic/plugins/pr_size_checker.rb +125 -0
  35. data/lib/dangermattic/plugins/tracks_checker.rb +72 -0
  36. data/lib/dangermattic/plugins/view_changes_checker.rb +46 -0
  37. data/spec/android_release_checker_spec.rb +93 -0
  38. data/spec/android_strings_checker_spec.rb +185 -0
  39. data/spec/android_unit_test_checker_spec.rb +343 -0
  40. data/spec/common_release_checker_spec.rb +70 -0
  41. data/spec/fixtures/android_unit_test_checker/Abc.java +7 -0
  42. data/spec/fixtures/android_unit_test_checker/AbcFeatureConfig.java +7 -0
  43. data/spec/fixtures/android_unit_test_checker/Abcdef.kt +5 -0
  44. data/spec/fixtures/android_unit_test_checker/AbcdefgViewHelper.java +7 -0
  45. data/spec/fixtures/android_unit_test_checker/AnotherViewHelper.kt +7 -0
  46. data/spec/fixtures/android_unit_test_checker/MyNewClass.java +7 -0
  47. data/spec/fixtures/android_unit_test_checker/Polygon.kt +3 -0
  48. data/spec/fixtures/android_unit_test_checker/Shape.kt +10 -0
  49. data/spec/fixtures/android_unit_test_checker/TestsINeedThem.java +5 -0
  50. data/spec/fixtures/android_unit_test_checker/TestsINeedThem.kt +7 -0
  51. data/spec/fixtures/android_unit_test_checker/TestsINeedThem2.kt +12 -0
  52. data/spec/fixtures/android_unit_test_checker/src/android/java/org/activities/MyActivity.kt +7 -0
  53. data/spec/fixtures/android_unit_test_checker/src/android/java/org/activities/MyJavaActivity.java +7 -0
  54. data/spec/fixtures/android_unit_test_checker/src/android/java/org/fragments/MyFragment.kt +6 -0
  55. data/spec/fixtures/android_unit_test_checker/src/android/java/org/fragments/MyNewJavaFragment.java +7 -0
  56. data/spec/fixtures/android_unit_test_checker/src/android/java/org/module/MyModule.java +13 -0
  57. data/spec/fixtures/android_unit_test_checker/src/android/java/org/view/ActionCardViewHolder.kt +22 -0
  58. data/spec/fixtures/android_unit_test_checker/src/android/java/org/view/MyRecyclerView.java +7 -0
  59. data/spec/fixtures/android_unit_test_checker/src/androidTest/java/org/test/AbcTests.java +5 -0
  60. data/spec/fixtures/android_unit_test_checker/src/androidTest/java/org/test/AnotherTestClass.java +7 -0
  61. data/spec/fixtures/android_unit_test_checker/src/androidTest/java/org/test/PolygonTest.kt +4 -0
  62. data/spec/fixtures/android_unit_test_checker/src/androidTest/java/org/test/TestMyNewClass.java +9 -0
  63. data/spec/fixtures/android_unit_test_checker/src/androidTest/java/org/test/ToolTest.kt +5 -0
  64. data/spec/fixtures/android_unit_test_checker/src/main/java/org/wordpress/android/widgets/NestedWebView.kt +32 -0
  65. data/spec/fixtures/android_unit_test_checker/src/main/java/org/wordpress/util/config/BloggingPromptsFeatureConfig.kt +23 -0
  66. data/spec/fixtures/android_unit_test_checker/src/test/java/org/test/TestsINeedThem.java +9 -0
  67. data/spec/github_utils_spec.rb +110 -0
  68. data/spec/ios_release_checker_spec.rb +191 -0
  69. data/spec/labels_checker_spec.rb +169 -0
  70. data/spec/manifest_pr_checker_spec.rb +140 -0
  71. data/spec/milestone_checker_spec.rb +222 -0
  72. data/spec/podfile_checker_spec.rb +595 -0
  73. data/spec/pr_size_checker_spec.rb +250 -0
  74. data/spec/spec_helper.rb +115 -0
  75. data/spec/tracks_checker_spec.rb +156 -0
  76. data/spec/view_changes_checker_spec.rb +168 -0
  77. metadata +341 -0
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Danger
4
+ # This plugin provides methods to check for the presence of unit tests for newly added classes in a pull request.
5
+ #
6
+ # @example Check missing unit tests using the default parameters:
7
+ # android_unit_test_checker.check_missing_tests
8
+ #
9
+ # @example Check missing unit tests while excluding certain classes, subclasses, and paths:
10
+ # android_unit_test_checker.check_missing_tests(
11
+ # classes_exceptions: [/ViewHolder$/],
12
+ # subclasses_exceptions: [/RecyclerView/],
13
+ # path_exceptions: ['*.java', 'org/app/ui/**']
14
+ # )
15
+ #
16
+ # @example Check missing unit tests with a custom bypass label:
17
+ # android_unit_test_checker.check_missing_tests(bypass_label: 'BypassTestCheck')
18
+ #
19
+ # @example Check missing unit tests excluding certain classes, subclasses and paths:
20
+ # android_unit_test_checker.check_missing_tests(classes_exceptions: [/ViewHolder$/], subclasses_exceptions: [/RecyclerView/], path_exceptions: ['*.java', 'org/app/ui/**'])
21
+ #
22
+ # @see Automattic/dangermattic
23
+ # @tags android, unit test, github, pull request
24
+ #
25
+ class AndroidUnitTestChecker < Plugin
26
+ ANY_CLASS_DETECTOR = /class\s+([A-Z]\w+)\s*(.*?)\s*{/m
27
+ NON_PRIVATE_CLASS_DETECTOR = /(?:\s|public|internal|protected|final|abstract|static)*class\s+([A-Z]\w+)\s*(.*?)\s*{/m
28
+ DEFAULT_CLASSES_EXCEPTIONS = [
29
+ /ViewHolder$/,
30
+ /Module$/,
31
+ /Button$/
32
+ ].freeze
33
+
34
+ DEFAULT_SUBCLASSES_EXCEPTIONS = [
35
+ /(Fragment|Activity)\b/,
36
+ /RecyclerView/,
37
+ /^BroadcastReceiver$/,
38
+ /^ContentProvider$/,
39
+ /Service$/,
40
+ /View$/,
41
+ /ViewGroup$/,
42
+ /Layout$/
43
+ ].freeze
44
+
45
+ DEFAULT_UNIT_TESTS_BYPASS_PR_LABEL = 'unit-tests-exemption'
46
+
47
+ # Check and warns about missing unit tests for a Git diff, with optional classes/subclasses to ignore and an
48
+ # optional PR label to bypass the checks.
49
+ #
50
+ # @param classes_exceptions [Array<String>] Optional list of regexes matching class names to exclude from the
51
+ # check.
52
+ # Defaults to DEFAULT_CLASSES_EXCEPTIONS.
53
+ # @param subclasses_exceptions [Array<String>] Optional list of regexes matching base class names to exclude from
54
+ # the check.
55
+ # Defaults to DEFAULT_SUBCLASSES_EXCEPTIONS.
56
+ # @param path_exceptions [Array<String>] Optional list of file paths to exclude from the check.
57
+ # Defaults to [].
58
+ # @param bypass_label [String] Optional label to indicate we can bypass the check. Defaults to
59
+ # DEFAULT_UNIT_TESTS_BYPASS_PR_LABEL.
60
+ #
61
+ # @return [void]
62
+ def check_missing_tests(classes_exceptions: DEFAULT_CLASSES_EXCEPTIONS,
63
+ subclasses_exceptions: DEFAULT_SUBCLASSES_EXCEPTIONS,
64
+ path_exceptions: [],
65
+ bypass_label: DEFAULT_UNIT_TESTS_BYPASS_PR_LABEL)
66
+ list = find_classes_missing_tests(
67
+ git_diff: git.diff,
68
+ classes_exceptions: classes_exceptions,
69
+ subclasses_exceptions: subclasses_exceptions,
70
+ path_exceptions: path_exceptions
71
+ )
72
+
73
+ return if list.empty?
74
+
75
+ if danger.github.pr_labels.include?(bypass_label)
76
+ list.each do |c|
77
+ warn("Class `#{c.classname}` is missing tests, but `#{bypass_label}` label was set to ignore this.")
78
+ end
79
+ else
80
+ list.each do |c|
81
+ failure("Please add tests for class `#{c.classname}` (or add `#{bypass_label}` label to ignore this).")
82
+ end
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ ClassViolation = Struct.new(:classname, :file)
89
+
90
+ # @param git_diff [Git::Diff] the git diff object
91
+ # @param classes_exceptions [Array<String>] Regexes matching class names to exclude from the check.
92
+ # @param subclasses_exceptions [Array<String>] Regexes matching base class names to exclude from the check
93
+ # @param path_exceptions [Array<String>] Regexes matching base class names to exclude from the check
94
+ #
95
+ # @return [Array<ClassViolation>] An array of `ClassViolation` objects for each added class that is missing a test
96
+ def find_classes_missing_tests(git_diff:, classes_exceptions:, subclasses_exceptions:, path_exceptions:)
97
+ violations = []
98
+ removed_classes = []
99
+ added_test_lines = []
100
+
101
+ # Parse the diff of each file, storing test lines for test files, and added/removed classes for non-test files
102
+ git_diff.each do |file_diff|
103
+ file_path = file_diff.path
104
+
105
+ next if path_exceptions.any? { |exception| File.fnmatch?(exception, file_path) }
106
+
107
+ if test_file?(path: file_path)
108
+ # Store added test lines from test files
109
+ added_test_lines += file_diff.patch.each_line.select do |line|
110
+ git_utils.change_type(diff_line: line) == :added
111
+ end
112
+ else
113
+ # Detect added classes (violations) and removed classes in non-test files
114
+ patch = file_diff.patch
115
+
116
+ violations += find_violations(
117
+ path: file_path,
118
+ diff_patch: patch,
119
+ classes_exceptions: classes_exceptions,
120
+ subclasses_exceptions: subclasses_exceptions
121
+ )
122
+
123
+ removed_classes += find_removed_classes(diff_patch: patch)
124
+ end
125
+ end
126
+
127
+ # We only want newly added classes, not if class signature was modified or line was moved
128
+ violations.reject! { |v| removed_classes.include?(v.classname) }
129
+
130
+ # For each remaining candidate, only keep the ones _not_ used in a new test.
131
+ # The regex will match usages of this class in any test file
132
+ violations.select { |v| added_test_lines.none? { |line| line =~ /\b#{v.classname}\b/ } }
133
+ end
134
+
135
+ # Finds added classes that potentially will require a test (violations) in the given file based on the changes in the diff patch.
136
+ #
137
+ # @param path [String] The file in the diff to check for violations.
138
+ # @param diff_patch [String] The diff patch containing the changes to the file.
139
+ # @param classes_exceptions [Array<String>] An array of class names that are exceptions and should be ignored.
140
+ # @param subclasses_exceptions [Array<String>] An array of class names whose subclasses should be ignored as well.
141
+ #
142
+ # @return [Array<ClassViolation>] An array of ClassViolation objects representing the violations found.
143
+ def find_violations(path:, diff_patch:, classes_exceptions:, subclasses_exceptions:)
144
+ added_lines = git_utils.added_lines(diff_patch: diff_patch)
145
+ matches = added_lines.scan(NON_PRIVATE_CLASS_DETECTOR)
146
+ matches.reject! do |m|
147
+ class_match_is_exception?(
148
+ m,
149
+ path,
150
+ classes_exceptions,
151
+ subclasses_exceptions
152
+ )
153
+ end
154
+
155
+ matches.map { |m| ClassViolation.new(m[0], path) }
156
+ end
157
+
158
+ # Finds the names of removed classes based on the removals the diff patch.
159
+ #
160
+ # @param diff_patch [String] The diff patch containing the changes to the file.
161
+ #
162
+ # @return [Array<String>] An array with the class names of the classes that were removed in the diff.
163
+ def find_removed_classes(diff_patch:)
164
+ removed_lines = git_utils.removed_lines(diff_patch: diff_patch)
165
+ matches = removed_lines.scan(ANY_CLASS_DETECTOR)
166
+ matches.map { |m| m[0] }
167
+ end
168
+
169
+ # @param match [Array<String>] match an array of captured substrings matching our `*_CLASS_DETECTOR` for a given line
170
+ # @param file [String] file the path to the file where that class declaration line was matched
171
+ # @param classes_exceptions [Array<String>] Regexes matching class names to exclude from the check.
172
+ # @param subclasses_exceptions [Array<String>] Regexes matching base class names to exclude from the check
173
+ #
174
+ # @return [void]
175
+ def class_match_is_exception?(match, file, classes_exceptions, subclasses_exceptions)
176
+ return true if classes_exceptions.any? { |re| match[0] =~ re }
177
+
178
+ subclass_regexp = File.extname(file) == '.java' ? /extends\s+([A-Z]\w+)/m : /\s*:\s*([A-Z]\w+)/m
179
+ subclass = match[1].scan(subclass_regexp)&.last&.last
180
+ subclasses_exceptions.any? { |re| subclass =~ re }
181
+ end
182
+
183
+ def test_file?(path:)
184
+ path.match? %r{/(test|androidTest).*\.(java|kt)$}
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Danger
4
+ # Plugin to perform generic checks related to releases.
5
+ # It can be used directly or via the specialised plugins `AndroidReleaseChecker` and `IosReleaseChecker`.
6
+ #
7
+ # @example Checking if a specific file has changed on a release branch:
8
+ # common_release_checker.check_file_changed(
9
+ # file_comparison: ->(path) { path == 'metadata/full_release_notes.txt' },
10
+ # message: 'Release notes have been modified on a release branch.',
11
+ # on_release_branch: true
12
+ # )
13
+ #
14
+ # @example Checking if release notes and store strings have changed:
15
+ # common_release_checker.check_release_notes_and_store_strings(
16
+ # release_notes_file: 'metadata/release_notes.txt',
17
+ # po_file: 'metadata/PlayStoreStrings.po'
18
+ # )
19
+ #
20
+ # @example Checking for changes in internal release notes:
21
+ # common_release_checker.check_internal_release_notes_changed
22
+ #
23
+ # @see Automattic/dangermattic
24
+ # @tags util, process, release
25
+ #
26
+ class CommonReleaseChecker < Plugin
27
+ DEFAULT_INTERNAL_RELEASE_NOTES = 'RELEASE-NOTES.txt'
28
+
29
+ MESSAGE_STORE_FILE_NOT_CHANGED = 'The `%s` file should be updated if the editorialized release notes file `%s` is being changed.'
30
+ MESSAGE_INTERNAL_RELEASE_NOTES_CHANGED = <<~WARNING
31
+ This PR contains changes to `%s`.
32
+ Note that these changes won't affect the final version of the release notes as this version is in code freeze.
33
+ Please, get in touch with a release manager if you want to update the final release notes.
34
+ WARNING
35
+
36
+ # Check if certain files have been modified, returning a warning or failure message based on the branch type.
37
+ #
38
+ # @param file_comparison [Proc] Function used to compare modified file paths.
39
+ # It should take a single argument, which is the path to a modified file,
40
+ # and return true if the file matches the desired condition.
41
+ # Example: `file_comparison = ->(file_path) { file_path.include?('app/') }`
42
+ #
43
+ # @param message [String] The message to display in the warning or failure output if the condition is met.
44
+ #
45
+ # @param on_release_branch [Boolean] If true, the check will only run on release branches, otherwise on non-release branches.
46
+ #
47
+ # @param report_type [Symbol] (optional) The type of report for the message. Types: :error, :warning (default), :message.
48
+ #
49
+ # @example Check if any modified file is under the 'app/' directory and emit a warning on release branches:
50
+ # check_file_changed(file_comparison: ->(file_path) { file_path.include?('app/') },
51
+ # message: 'Some files in the "app/" directory have been modified. Please review the changes.',
52
+ # on_release_branch: true)
53
+ #
54
+ # @example Check if a specific file has been modified and emit a failure on non-release branches:
55
+ # check_file_changed(file_comparison: ->(file_path) { file_path == 'path/to/file/DoNotChange.java' },
56
+ # message: 'The "DoNotChange.java" file has been modified. This change is not allowed on non-release branches.',
57
+ # on_release_branch: false,
58
+ # report_type: :error)
59
+ #
60
+ # @return [void]
61
+ def check_file_changed(file_comparison:, message:, on_release_branch:, report_type: :warning)
62
+ has_modified_file = git_utils.all_changed_files.any?(&file_comparison)
63
+
64
+ should_be_changed = (on_release_branch == github_utils.release_branch?)
65
+ return unless should_be_changed && has_modified_file
66
+
67
+ reporter.report(message: message, type: report_type)
68
+ end
69
+
70
+ # Check if the release notes and store strings files are correctly updated after a modification to the release notes.
71
+ #
72
+ # @param release_notes_file [String] The name of the release notes file that should be checked for modifications.
73
+ # Example: 'metadata/release_notes.txt'
74
+ #
75
+ # @param po_file [String] The name of the store strings file that should be checked for modifications.
76
+ # Example: 'metadata/PlayStoreStrings.po'
77
+ #
78
+ # @example Check if the release notes file 'release_notes.txt' is modified, and the 'PlayStoreStrings.po' file is not modified, posting a message:
79
+ # check_release_notes_and_store_strings(release_notes_file: 'release_notes.txt', po_file: 'PlayStoreStrings.po')
80
+ #
81
+ # @return [void]
82
+ def check_release_notes_and_store_strings(release_notes_file:, po_file:)
83
+ has_modified_release_notes = danger.git.modified_files.any? { |f| f == release_notes_file }
84
+ has_modified_app_store_strings = danger.git.modified_files.any? { |f| f == po_file }
85
+
86
+ return unless has_modified_release_notes && !has_modified_app_store_strings
87
+
88
+ message(format(MESSAGE_STORE_FILE_NOT_CHANGED, po_file, release_notes_file))
89
+ end
90
+
91
+ # Check if there are changes to the internal release notes file in the release branch and emit a warning if that's the case.
92
+ #
93
+ # @param release_notes_file [String] (optional) The path to the internal release notes file.
94
+ # Defaults to the `DEFAULT_INTERNAL_RELEASE_NOTES` constant if not provided.
95
+ # @param report_type [Symbol] (optional) The type of report for the message. Types: :error, :warning (default), :message.
96
+ #
97
+ # @example Checking for changes in the default internal release notes file:
98
+ # check_internal_release_notes_changed
99
+ #
100
+ # @example Checking for changes in a custom internal release notes file at a specific path:
101
+ # check_internal_release_notes_changed(release_notes_file: '/path/to/internal_release_notes.txt')
102
+ #
103
+ # @return [void]
104
+ def check_internal_release_notes_changed(release_notes_file: DEFAULT_INTERNAL_RELEASE_NOTES, report_type: :warning)
105
+ check_file_changed(
106
+ file_comparison: ->(path) { path == release_notes_file },
107
+ message: format(MESSAGE_INTERNAL_RELEASE_NOTES_CHANGED, release_notes_file),
108
+ on_release_branch: true,
109
+ report_type: report_type
110
+ )
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Danger
4
+ # Represents a utility plugin for working with Git: to check added and modified lines in Git diffs,
5
+ # to determine the type of change (added, removed, or other) in a diff line, and to retrieve lists of
6
+ # added, modified, and deleted files.
7
+ #
8
+ # @example Check if there is a "TODO" in Ruby files:
9
+ # git_utils.check_added_diff_lines(
10
+ # file_selector: ->(path) { path.end_with?('.rb') },
11
+ # line_matcher: ->(line) { line.include?('TODO') },
12
+ # message: 'Found a TODO in a Ruby file'
13
+ # )
14
+ #
15
+ # @example Get added lines from a diff patch:
16
+ # added_lines = git_utils.added_lines(diff_patch: diff_patch)
17
+ #
18
+ # @example Get removed lines from a diff patch:
19
+ # removed_lines = git_utils.removed_lines(diff_patch: diff_patch)
20
+ #
21
+ # @example Determining the change type of a diff line:
22
+ # git_utils.change_type(diff_line: "+ new line added")
23
+ # #=> :added
24
+ #
25
+ # git_utils.change_type(diff_line: "- line removed")
26
+ # #=> :removed
27
+ #
28
+ # git_utils.change_type(diff_line: " context line")
29
+ # #=> :other
30
+ #
31
+ # @example Select removed lines from a diff patch:
32
+ # removed_lines = git_utils.select_lines(diff_patch: diff_patch, change_type: :removed)
33
+ #
34
+ # @see Automattic/dangermattic
35
+ # @tags tool, util, git
36
+ #
37
+ class GitUtils < Plugin
38
+ # Check added lines in a PR for a specific pattern and issue a warning or failure message when found.
39
+ #
40
+ # @param file_selector [Proc] A block to select the files in the PR.
41
+ # The block should take a file path as input and return true if the file should be checked.
42
+ #
43
+ # @param line_matcher [Proc] A block that will select the diff lines to report.
44
+ # The block should take a line of text as input and return true if the line matches the pattern.
45
+ #
46
+ # @param message [String] The warning or failure message to display when the pattern is found in a line.
47
+ #
48
+ # @param report_type [Symbol] (optional) Type of report (:error, :warning, :message) whenever a line matches the criteria. Default is :warning.
49
+ #
50
+ # @example Checking for added lines containing 'FIXME' and failing the build:
51
+ # check_added_diff_lines(file_selector: ->(path) { File.extname(path) == ('.swift') }, line_matcher: ->(line) { line.include?("FIXME") }, message: "A FIXME was added, failing build.", report_type: :error)
52
+ #
53
+ # @return [void]
54
+ def check_added_diff_lines(file_selector:, line_matcher:, message:, report_type: :warning)
55
+ modified_files = added_and_modified_files.select(&file_selector)
56
+
57
+ matches = matching_lines_in_diff_files(
58
+ files: modified_files,
59
+ line_matcher: line_matcher,
60
+ change_type: :added
61
+ )
62
+
63
+ matches.each do |match|
64
+ match.lines.each do |line|
65
+ final_message = <<~MESSAGE
66
+ #{message}
67
+ File `#{match.file}`:
68
+ ```diff
69
+ #{line.chomp}
70
+ ```
71
+ MESSAGE
72
+
73
+ reporter.report(message: final_message, type: report_type)
74
+ end
75
+ end
76
+ end
77
+
78
+ MatchedData = Struct.new(:file, :lines)
79
+
80
+ # Matches diff lines in the provided files based on the line matcher and change type
81
+ #
82
+ # @param files [Array<String>] List of file names to check
83
+ # @param line_matcher [Proc] A callable that takes a line and returns true if it matches the desired pattern
84
+ # @param change_type [Symbol, nil] Change type to filter lines (e.g., :added, :removed) or nil for no filter
85
+ # @return [Array<MatchedData>] Array of MatchedData objects representing matched lines in files
86
+ def matching_lines_in_diff_files(files:, line_matcher:, change_type: nil)
87
+ matched_data = []
88
+
89
+ files.each do |file|
90
+ matched_lines = []
91
+
92
+ diff = danger.git.diff_for_file(file)
93
+
94
+ diff.patch.each_line do |line|
95
+ matched_lines << line if line_matcher.call(line) && (change_type.nil? || change_type(diff_line: line) == change_type)
96
+ end
97
+
98
+ matched_data << MatchedData.new(file, matched_lines) unless matched_lines.empty?
99
+ end
100
+
101
+ matched_data
102
+ end
103
+
104
+ # Determine the type of change for a given line in a git diff.
105
+ #
106
+ # @param diff_line [String] The line from a git diff that needs to be classified.
107
+ #
108
+ # @return [Symbol] The type of change for the given diff line. Possible values are:
109
+ # - :added for added lines
110
+ # - :removed for removed lines
111
+ # - :other for any other type of lines
112
+ def change_type(diff_line:)
113
+ if diff_line.start_with?('+') && !diff_line.start_with?('+++ ')
114
+ :added
115
+ elsif diff_line.start_with?('-') && !diff_line.start_with?('--- ')
116
+ :removed
117
+ else
118
+ :other
119
+ end
120
+ end
121
+
122
+ # Get the list of added and modified files in the current Pull Request.
123
+ #
124
+ # @return [Array<String>] An array containing the file paths of added and modified files.
125
+ def added_and_modified_files
126
+ danger.git.added_files + danger.git.modified_files
127
+ end
128
+
129
+ # Get the list of all files added, modified and deleted in the current Pull Request.
130
+ #
131
+ # @return [Array<String>] An array containing the file paths of all changed files.
132
+ #
133
+ def all_changed_files
134
+ danger.git.added_files + danger.git.modified_files + danger.git.deleted_files
135
+ end
136
+
137
+ # Returns the lines that were added in the given diff patch.
138
+ #
139
+ # @param diff_patch [String] The diff patch containing the changes.
140
+ #
141
+ # @return [String] A concatenated string of added lines.
142
+ def added_lines(diff_patch:)
143
+ select_lines(diff_patch: diff_patch, change_type: :added)
144
+ end
145
+
146
+ # Returns the lines that were removed in the given diff patch.
147
+ #
148
+ # @param diff_patch [String] The diff patch containing the changes.
149
+ #
150
+ # @return [String] A concatenated string of removed lines.
151
+ def removed_lines(diff_patch:)
152
+ select_lines(diff_patch: diff_patch, change_type: :removed)
153
+ end
154
+
155
+ # Selects lines of a specific change type (added or removed) from the given diff patch.
156
+ #
157
+ # @param diff_patch [String] The diff patch containing the changes.
158
+ # @param change_type [Symbol] The desired change type (:added or :removed).
159
+ #
160
+ # @return [String] A concatenated string of selected lines of the specified change type.
161
+ def select_lines(diff_patch:, change_type:)
162
+ selected_lines = diff_patch.lines.select { |line| change_type(diff_line: line) == change_type }
163
+ selected_lines.map { |line| line[1..] }.join
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Danger
4
+ # Provides common GitHub utility methods related to Pull Requests in a Danger context.
5
+ #
6
+ # @example Checking if the branch is a release or hotfix branch:
7
+ # github_utils.release_branch? #=> true or false
8
+ #
9
+ # @example Checking if there are active reviewers on the PR:
10
+ # github_utils.active_reviewers? #=> true or false
11
+ #
12
+ # @example Checking if there are requested teams or reviewers on the PR:
13
+ # github_utils.requested_reviewers? #=> true or false
14
+ #
15
+ # @example Checking if the branch is a main branch (trunk, main, master, or develop):
16
+ # github_utils.main_branch? #=> true or false
17
+ #
18
+ # @example Checking if the PR is a work-in-progress (WIP) based on labels or title:
19
+ # github_utils.wip_feature? #=> true or false
20
+ #
21
+ # @see Automattic/dangermattic
22
+ # @tags tool, util
23
+ #
24
+ class GithubUtils < Plugin
25
+ # Checks if there are active reviewers providing feedback and potentially changing the state of the PR
26
+ # (e.g., approved, changes requested).
27
+ #
28
+ # @return [Boolean] True if there are active reviewers, otherwise false.
29
+ def active_reviewers?
30
+ repo_name = github.pr_json['base']['repo']['full_name']
31
+ pr_number = github.pr_json['number']
32
+
33
+ !github.api.pull_request_reviews(repo_name, pr_number).empty?
34
+ end
35
+
36
+ # Checks if there are requested teams or reviewers who haven't reacted yet.
37
+ #
38
+ # @return [Boolean] True if there are requested teams or reviewers, otherwise false.
39
+ def requested_reviewers?
40
+ has_requested_reviews = !github.pr_json['requested_teams'].to_a.empty? || !github.pr_json['requested_reviewers'].to_a.empty?
41
+ has_requested_reviews || active_reviewers?
42
+ end
43
+
44
+ # Checks if the current branch is a main branch (trunk, main, master, or develop).
45
+ #
46
+ # @return [Boolean] True if the branch is a main branch, otherwise false.
47
+ def main_branch?
48
+ %w[trunk main master develop].include?(github.branch_for_base)
49
+ end
50
+
51
+ # Checks if the current branch is a release or hotfix branch.
52
+ #
53
+ # @return [Boolean] True if the branch is a release or hotfix branch, otherwise false.
54
+ def release_branch?
55
+ github.branch_for_base.start_with?('release/') || github.branch_for_base.start_with?('hotfix/')
56
+ end
57
+
58
+ # Checks if the PR is a work-in-progress (WIP) based on labels or title.
59
+ #
60
+ # @return [Boolean] True if the PR is a work-in-progress, otherwise false.
61
+ def wip_feature?
62
+ has_wip_label = github.pr_labels.any? { |label| label.include?('WIP') }
63
+ has_wip_title = github.pr_title.include?('WIP')
64
+
65
+ has_wip_label || has_wip_title
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Danger
4
+ # Handles reporting messages in the context of Danger, allowing the generation of
5
+ # warnings, errors, and simple messages in a Danger run.
6
+ #
7
+ # @example Reporting a Warning
8
+ # reporter.report(message: "This is a warning message", type: :warning)
9
+ #
10
+ # @example Reporting an Error
11
+ # reporter.report(message: "This is an error message", type: :error)
12
+ #
13
+ # @see Automattic/dangermattic
14
+ # @tags tool, util, danger
15
+ #
16
+ class Reporter < Plugin
17
+ # Report a message to be posted by Danger as an error (failing the build), a warning or a simple message.
18
+ #
19
+ # @param message [String] The message to be reported to Danger.
20
+ # @param type [Symbol] The type of report. Possible values:
21
+ # - :warning (default): Reports a warning.
22
+ # - :error: Reports an error.
23
+ # - :message: Reports a simple message.
24
+ # - Any other value or nil: Takes no action.
25
+ #
26
+ # @return [void]
27
+ def report(message:, type: :warning)
28
+ case type
29
+ when :error
30
+ failure(message)
31
+ when :warning
32
+ warn(message)
33
+ when :message
34
+ danger.message(message)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Danger
4
+ # Plugin for performing iOS / macOS release-related checks in a pull request.
5
+ #
6
+ # @example Checking for changes in Core Data models on a release branch:
7
+ # ios_release_checker.check_core_data_model_changed
8
+ #
9
+ # @example Checking for modified Localizable.strings on a regular branch:
10
+ # ios_release_checker.check_modified_localizable_strings
11
+ #
12
+ # @example Checking for synchronization between release notes and App Store strings:
13
+ # ios_release_checker.check_release_notes_and_app_store_strings
14
+ #
15
+ # @see Automattic/dangermattic
16
+ # @tags ios, macos, process, release
17
+ #
18
+ class IosReleaseChecker < Plugin
19
+ LOCALIZABLE_STRINGS_FILE = 'Localizable.strings'
20
+ BASE_STRINGS_FILE = "en.lproj/#{LOCALIZABLE_STRINGS_FILE}".freeze
21
+
22
+ MESSAGE_STRINGS_FILE_UPDATED = "The `#{LOCALIZABLE_STRINGS_FILE}` files should only be updated on release branches, when the translations are downloaded by our automation.".freeze
23
+ MESSAGE_BASE_STRINGS_FILE_UPDATED = "The `#{BASE_STRINGS_FILE}` file should only be updated before creating a release branch.".freeze
24
+ MESSAGE_TRANSLATION_FILE_UPDATED = "Translation files `*.lproj/#{LOCALIZABLE_STRINGS_FILE}` should only be updated on a release branch.".freeze
25
+ MESSAGE_CORE_DATA_UPDATED = 'Do not edit an existing Core Data model in a release branch unless it hasn\'t been released to testers yet. ' \
26
+ 'Instead create a new model version and merge back to develop soon.'
27
+
28
+ # Checks if an existing Core Data model has been edited in a release branch.
29
+ #
30
+ # @param report_type [Symbol] (optional) The type of report for the message. Types: :error, :warning (default), :message.
31
+ #
32
+ # @return [void]
33
+ def check_core_data_model_changed(report_type: :warning)
34
+ common_release_checker.check_file_changed(
35
+ file_comparison: ->(path) { File.extname(path) == '.xcdatamodeld' },
36
+ message: MESSAGE_CORE_DATA_UPDATED,
37
+ on_release_branch: true,
38
+ report_type: report_type
39
+ )
40
+ end
41
+
42
+ # Checks if any Localizable.strings file has been modified on a release branch, otherwise reporting a warning.
43
+ #
44
+ # @param report_type [Symbol] (optional) The type of report for the message. Types: :error, :warning (default), :message.
45
+ #
46
+ # @return [void]
47
+ def check_modified_localizable_strings_on_release(report_type: :warning)
48
+ common_release_checker.check_file_changed(
49
+ file_comparison: ->(path) { File.basename(path) == LOCALIZABLE_STRINGS_FILE },
50
+ message: MESSAGE_STRINGS_FILE_UPDATED,
51
+ on_release_branch: false,
52
+ report_type: report_type
53
+ )
54
+ end
55
+
56
+ # Checks if the en.lproj/Localizable.strings file has been modified on a regular branch, otherwise reporting a warning.
57
+ #
58
+ # @param report_type [Symbol] (optional) The type of report for the message. Types: :error, :warning (default), :message.
59
+ #
60
+ # @return [void]
61
+ def check_modified_en_strings_on_regular_branch(report_type: :warning)
62
+ common_release_checker.check_file_changed(
63
+ file_comparison: ->(path) { base_strings_file?(path: path) },
64
+ message: MESSAGE_BASE_STRINGS_FILE_UPDATED,
65
+ on_release_branch: true,
66
+ report_type: report_type
67
+ )
68
+ end
69
+
70
+ # Checks if a translation file (*.lproj/Localizable.strings) has been modified on a release branch, otherwise reporting a warning.
71
+ #
72
+ # @param report_type [Symbol] (optional) The type of report for the message. Types: :error, :warning (default), :message.
73
+ #
74
+ # @return [void]
75
+ def check_modified_translations_on_release_branch(report_type: :warning)
76
+ common_release_checker.check_file_changed(
77
+ file_comparison: ->(path) { !base_strings_file?(path: path) && File.basename(path) == LOCALIZABLE_STRINGS_FILE },
78
+ message: MESSAGE_TRANSLATION_FILE_UPDATED,
79
+ on_release_branch: false,
80
+ report_type: report_type
81
+ )
82
+ end
83
+
84
+ # Checks if changes made to the release notes are also followed by changes in the App Store strings file.
85
+ #
86
+ # @return [void]
87
+ def check_release_notes_and_app_store_strings
88
+ common_release_checker.check_release_notes_and_store_strings(
89
+ release_notes_file: 'Resources/release_notes.txt',
90
+ po_file: 'Resources/AppStoreStrings.po'
91
+ )
92
+ end
93
+
94
+ private
95
+
96
+ # Checks if a given path corresponds to the base (English) strings file, en.lproj/Localizable.strings.
97
+ #
98
+ # @return [Boolean] true if path is the base strings file
99
+ def base_strings_file?(path:)
100
+ base_strings_path_components = Pathname.new(BASE_STRINGS_FILE).each_filename.to_a
101
+ path_components = Pathname.new(path).each_filename.to_a
102
+
103
+ base_strings_path_components == path_components.last(base_strings_path_components.length)
104
+ end
105
+ end
106
+ end