danger-dangermattic 1.2.3 → 1.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20a5e57d91cc796def3533c72a3a674ea494356982fa78015ccc016ce19b61c9
4
- data.tar.gz: 376d14c60ba7aadc6107b4a6de9a40dc968a658a0fe326aaad208866bf4583e9
3
+ metadata.gz: 455052da651ef9af5d20ffc6be33a06ab961393e4c0adc8d3d87e27beba62efd
4
+ data.tar.gz: f04b1494ea66bc1fb3677e69658c08fa40b7373f548215f424ffd6f5fd0a4148
5
5
  SHA512:
6
- metadata.gz: 10ed4e5e5772cc7631470fe591c79004a5a237ddaffb6c708204d4823a204f15cc068530c5e8b05bc0f150577037f93bcb675a93ae47eb4c71a5d1b245e658fe
7
- data.tar.gz: 06c85ea386f290706300db05c3a07439859bda94d025a6dd9bab9068786a2040b247c09c8d1314c31f44c09d07e5aded50434d3092c55aeca1ff9fce2aad78d2
6
+ metadata.gz: 58e0ffcb0c9ee7ce6f6a9080dc95e5aa8d9fe21eac85b576782625c4a528d0ebdc7069498fc6e965345f23d159eba6a186c7a9074f33ba38988e8bd3e24cc70c
7
+ data.tar.gz: 5401b6ce0765675148c5a6951c4f1279be562a3bc55a0e9dc824d6e0fd92a123738cdef5b8258fb734e8427939c206af845dac89d89d3192ee0f32f6ac2c0c3e
@@ -41,13 +41,13 @@ steps:
41
41
  #################
42
42
  # Danger Lint
43
43
  #################
44
- - label: "☢️ Lint (Danger)"
44
+ - label: ":danger: Lint (Danger)"
45
45
  key: dangerlint
46
46
  command: |
47
47
  echo "--- :rubygems: Setting up Gems"
48
48
  bundle install
49
49
 
50
- echo "--- ☢️ Run Danger Lint"
50
+ echo "--- :danger: Run Danger Lint"
51
51
  bundle exec danger plugins lint
52
52
  plugins: *common_plugins
53
53
 
@@ -0,0 +1,23 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ day: "monday"
8
+ open-pull-requests-limit: 10
9
+ groups:
10
+ actions-minor-patch:
11
+ patterns:
12
+ - '*'
13
+ update-types:
14
+ - minor
15
+ - patch
16
+ actions-major:
17
+ patterns:
18
+ - '*'
19
+ update-types:
20
+ - major
21
+ cooldown:
22
+ default-days: 7
23
+ semver-major-days: 14
@@ -11,19 +11,34 @@ All jobs are run on `ubuntu-latest`.
11
11
  This workflow is an independent check (not using Danger) to verify if the labels on an issue match specified regex patterns.
12
12
 
13
13
  ### Inputs:
14
- - `label-format-list`: JSON list of regex formats expected for the labels (default: `[".*"]`)
14
+ - `label-format-list`: JSON array of regex strings expected for the labels (default: `[".*"]`)
15
15
  - `label-error-message`: Error message when labels don't match
16
- - `label-success-message`: Success message when labels match
16
+ - `label-success-message`: Deprecated and ignored. Kept only for backward compatibility with existing callers, and scheduled for removal in the next major release.
17
17
  - `cancel-running-jobs`: Cancel in-progress jobs when new ones are created (default: `true`)
18
18
 
19
+ Example:
20
+
21
+ ```yaml
22
+ with:
23
+ label-format-list: |
24
+ [
25
+ "^\\[.+\\]",
26
+ "^[[:alnum:]]"
27
+ ]
28
+ ```
29
+
30
+ Backslashes in regex patterns must be doubled because the workflow parses the input as JSON.
31
+
19
32
  ### Secrets:
20
33
  - `github-token`: Required GitHub token
34
+ - The token must resolve through `gh api user` to the same login that appears on issue comments, because the workflow only manages comments authored by that login.
21
35
 
22
36
  ### Job: `check-issue-labels`
23
37
  - Permissions: `issues: write`
24
38
  - Main step: "🏷️ Check Issue Labels"
25
39
  - Checks if issue labels match the specified regex patterns
26
- - Posts a comment on the issue with success or error message
40
+ - Updates a managed comment authored by the configured token when labels are missing
41
+ - Removes that managed comment when labels become valid
27
42
 
28
43
  ## Retry Buildkite Step on Pull Request Events
29
44
 
@@ -4,7 +4,7 @@ on:
4
4
  workflow_call:
5
5
  inputs:
6
6
  label-format-list:
7
- description: The Regex formats expected for the labels; must be a JSON list
7
+ description: The regex formats expected for the labels; must be a JSON array of strings
8
8
  default: |
9
9
  [
10
10
  ".*"
@@ -17,8 +17,8 @@ on:
17
17
  type: string
18
18
  required: false
19
19
  label-success-message:
20
- description: Message to be posted when the labels set fulfill the entire list of expected formats.
21
- default: ✅ Yay, issue looks great!
20
+ description: Deprecated and ignored. Kept only for backward compatibility with existing callers, and scheduled for removal in the next major release.
21
+ default: ''
22
22
  type: string
23
23
  required: false
24
24
  cancel-running-jobs:
@@ -42,22 +42,103 @@ jobs:
42
42
  steps:
43
43
  - name: 🏷️ Check Issue Labels
44
44
  env:
45
- GITHUB_TOKEN: ${{ secrets.github-token }}
45
+ GH_TOKEN: ${{ secrets.github-token }}
46
46
  GH_REPO: ${{ github.repository }}
47
47
  ISSUE_NUMBER: ${{ github.event.issue.number }}
48
48
  ISSUE_LABELS: ${{ toJSON(github.event.issue.labels.*.name) }}
49
49
  LABEL_REGEX_LIST: ${{ inputs.label-format-list }}
50
- ISSUE_SUCCESS_COMMENT: >
51
- ${{ inputs.label-success-message }}
52
- <!-- generated_by_dangermattic -->
53
- ISSUE_ERROR_COMMENT: >
54
- ${{ inputs.label-error-message }}
55
- <!-- generated_by_dangermattic -->
50
+ DEPRECATED_SUCCESS_MESSAGE: ${{ inputs.label-success-message }}
51
+ ISSUE_ERROR_MESSAGE: ${{ inputs.label-error-message }}
56
52
  run: |
57
- #!/bin/bash
53
+ #!/usr/bin/env bash
54
+
55
+ set -euo pipefail
58
56
 
59
- readarray -t labels < <(echo "$ISSUE_LABELS" | jq -r '.[]')
60
- readarray -t label_regex_list < <(echo "$LABEL_REGEX_LIST" | jq -r '.[]')
57
+ comment_marker='<!-- generated_by_dangermattic -->'
58
+ authenticated_login=''
59
+ managed_comment_id=''
60
+ declare -a managed_comment_ids=()
61
+
62
+ if [ -n "$DEPRECATED_SUCCESS_MESSAGE" ]; then
63
+ echo "⚠️ label-success-message is deprecated and ignored. Success now removes the managed workflow comment."
64
+ fi
65
+
66
+ build_comment_body() {
67
+ local message="$1"
68
+
69
+ printf '%s\n%s' "$message" "$comment_marker"
70
+ }
71
+
72
+ load_authenticated_login() {
73
+ authenticated_login="$(gh api user --jq '.login')"
74
+
75
+ if [ -z "$authenticated_login" ] || [ "$authenticated_login" = 'null' ]; then
76
+ echo '❌ Unable to determine the authenticated GitHub login for managed comment filtering.'
77
+ exit 1
78
+ fi
79
+ }
80
+
81
+ load_managed_comment_ids() {
82
+ readarray -t managed_comment_ids < <(
83
+ gh api "repos/$GH_REPO/issues/$ISSUE_NUMBER/comments" --paginate |
84
+ jq -r --arg author "$authenticated_login" --arg marker "$comment_marker" '.[] | select(.user.login == $author) | select((.body // "") | contains($marker)) | .id'
85
+ )
86
+
87
+ if [ "${#managed_comment_ids[@]}" -gt 0 ]; then
88
+ managed_comment_id="${managed_comment_ids[$((${#managed_comment_ids[@]} - 1))]}"
89
+ else
90
+ managed_comment_id=''
91
+ fi
92
+ }
93
+
94
+ delete_extra_managed_comments() {
95
+ local duplicate_count=0
96
+
97
+ duplicate_count=$((${#managed_comment_ids[@]} - 1))
98
+ if [ "$duplicate_count" -le 0 ]; then
99
+ return
100
+ fi
101
+
102
+ for comment_id in "${managed_comment_ids[@]:0:$duplicate_count}"; do
103
+ echo "🧹 Deleting duplicate managed comment $comment_id"
104
+ gh api --method DELETE "repos/$GH_REPO/issues/comments/$comment_id" > /dev/null
105
+ done
106
+ }
107
+
108
+ upsert_managed_comment() {
109
+ local comment_body="$1"
110
+
111
+ if [ -n "$managed_comment_id" ]; then
112
+ echo "✍️ Updating managed comment $managed_comment_id on issue $ISSUE_NUMBER"
113
+ gh api --method PATCH "repos/$GH_REPO/issues/comments/$managed_comment_id" --raw-field "body=$comment_body" > /dev/null
114
+ else
115
+ echo "✍️ Creating managed comment on issue $ISSUE_NUMBER"
116
+ gh api --method POST "repos/$GH_REPO/issues/$ISSUE_NUMBER/comments" --raw-field "body=$comment_body" > /dev/null
117
+ fi
118
+ }
119
+
120
+ delete_managed_comments() {
121
+ if [ "${#managed_comment_ids[@]}" -eq 0 ]; then
122
+ echo "🧼 No managed comment found to delete."
123
+ return
124
+ fi
125
+
126
+ for comment_id in "${managed_comment_ids[@]}"; do
127
+ echo "🧼 Deleting managed comment $comment_id"
128
+ gh api --method DELETE "repos/$GH_REPO/issues/comments/$comment_id" > /dev/null
129
+ done
130
+ }
131
+
132
+ readarray -t labels < <(printf '%s\n' "$ISSUE_LABELS" | jq -r '.[]')
133
+
134
+ if ! printf '%s\n' "$LABEL_REGEX_LIST" | jq -e 'type == "array" and all(.[]; type == "string")' > /dev/null; then
135
+ echo "❌ LABEL_REGEX_LIST must be a JSON array of strings."
136
+ echo ' Example: ["^\\[.+\\]", "^[[:alnum:]]"]'
137
+ echo ' Regex backslashes must be doubled because the input is parsed as JSON.'
138
+ exit 1
139
+ fi
140
+
141
+ readarray -t label_regex_list < <(printf '%s\n' "$LABEL_REGEX_LIST" | jq -r '.[]')
61
142
 
62
143
  all_patterns_matched=true
63
144
 
@@ -78,22 +159,14 @@ jobs:
78
159
  fi
79
160
  done
80
161
 
81
- ISSUE_COMMENT=''
162
+ load_authenticated_login
163
+ load_managed_comment_ids
164
+ delete_extra_managed_comments
165
+
82
166
  if [ "$all_patterns_matched" = true ]; then
83
167
  echo "✅ All regex patterns have at least one match."
84
- ISSUE_COMMENT="$ISSUE_SUCCESS_COMMENT"
168
+ delete_managed_comments
85
169
  else
86
170
  echo "❌ Not all regex patterns have at least one match."
87
- ISSUE_COMMENT="$ISSUE_ERROR_COMMENT"
88
- fi
89
-
90
- set +e
91
- echo "✍️ Attempting to edit existing comment on issue $ISSUE_NUMBER, if it exists:"
92
- gh issue comment $ISSUE_NUMBER --body "$ISSUE_COMMENT" --edit-last
93
- comment_update_status=$?
94
- set -e
95
-
96
- if [ $comment_update_status -ne 0 ]; then
97
- echo "✍️ Adding new comment on issue $ISSUE_NUMBER:"
98
- gh issue comment $ISSUE_NUMBER --body "$ISSUE_COMMENT"
171
+ upsert_managed_comment "$(build_comment_body "$ISSUE_ERROR_MESSAGE")"
99
172
  fi
@@ -30,7 +30,7 @@ jobs:
30
30
  with:
31
31
  fetch-depth: 100
32
32
  - name: 💎 Ruby Setup
33
- uses: ruby/setup-ruby@v1
33
+ uses: ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f # v1.310.0
34
34
  with:
35
35
  bundler-cache: true
36
36
  - name: ☢️ Danger PR Check
data/AGENTS.md ADDED
@@ -0,0 +1,35 @@
1
+ ## Overview
2
+
3
+ Dangermattic is a shared collection of Danger plugins used across Automattic's mobile repositories.
4
+ It provides reusable Danger rules for PR checks, code review automation, and CI enforcement.
5
+
6
+ ## Bootstrap
7
+
8
+ Requires Ruby at the version specified in `.ruby-version`.
9
+
10
+ ```bash
11
+ bundle install
12
+ ```
13
+
14
+ ## Commands
15
+
16
+ - `bundle exec rake` — run all checks (specs + RuboCop + Danger lint)
17
+ - `bundle exec rspec` — run tests only
18
+ - `bundle exec rubocop` — run linter only
19
+
20
+ ## Project Structure
21
+
22
+ - `lib/dangermattic/plugins/` — Danger plugin implementations
23
+ - `lib/dangermattic/plugins/common/` — shared helpers used across plugins
24
+ - `spec/` — RSpec tests; each plugin has a matching `*_spec.rb`
25
+ - `spec/fixtures/` — test fixtures
26
+
27
+ ## Conventions
28
+
29
+ - Tests written with RSpec
30
+ - CI via Buildkite, see `.buildkite`
31
+ - Gem releases are triggered by Git tags pushed to the remote and run in CI.
32
+
33
+ ## Pitfalls
34
+
35
+ - Together with unit tests, `bundle exec danger plugins lint` must also pass — it validates plugin metadata and code correctness.
data/CHANGELOG.md CHANGED
@@ -20,6 +20,18 @@ _None_
20
20
 
21
21
  _None_
22
22
 
23
+ ## 1.3.0
24
+
25
+ ### New Features
26
+
27
+ - Added `android_strings_checker.check_existing_strings_not_modified`, which fails when the value of an existing translatable `<string>` is changed in place (rather than added under a new key). This enforces string-key immutability, which keeps in-progress translations valid in a continuous-localization setup. [#126]
28
+
29
+ ## 1.2.4
30
+
31
+ ### Internal Changes
32
+
33
+ - `pr_size_checker` and `manifest_pr_checker`: optimize performance for large PRs [#103]
34
+
23
35
  ## 1.2.3
24
36
 
25
37
  ### Bug Fixes
data/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
data/Gemfile.lock CHANGED
@@ -1,8 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- danger-dangermattic (1.2.3)
5
- danger (~> 9.4)
4
+ danger-dangermattic (1.3.0)
5
+ danger (~> 9.5, >= 9.5.3)
6
6
  danger-plugin-api (~> 1.0)
7
7
  danger-rubocop (~> 0.13)
8
8
  rubocop (~> 1.63)
@@ -10,25 +10,24 @@ PATH
10
10
  GEM
11
11
  remote: https://rubygems.org/
12
12
  specs:
13
- activesupport (8.0.2)
13
+ activesupport (8.1.3)
14
14
  base64
15
- benchmark (>= 0.3)
16
15
  bigdecimal
17
16
  concurrent-ruby (~> 1.0, >= 1.3.1)
18
17
  connection_pool (>= 2.2.5)
19
18
  drb
20
19
  i18n (>= 1.6, < 2)
20
+ json
21
21
  logger (>= 1.4.2)
22
22
  minitest (>= 5.1)
23
23
  securerandom (>= 0.3)
24
24
  tzinfo (~> 2.0, >= 2.0.5)
25
25
  uri (>= 0.13.1)
26
- addressable (2.8.7)
27
- public_suffix (>= 2.0.2, < 7.0)
26
+ addressable (2.9.0)
27
+ public_suffix (>= 2.0.2, < 8.0)
28
28
  ast (2.4.3)
29
29
  base64 (0.3.0)
30
- benchmark (0.4.1)
31
- bigdecimal (3.2.2)
30
+ bigdecimal (4.0.1)
32
31
  claide (1.1.0)
33
32
  claide-plugins (0.9.2)
34
33
  cork
@@ -36,8 +35,8 @@ GEM
36
35
  open4 (~> 1.3)
37
36
  coderay (1.1.3)
38
37
  colored2 (3.1.2)
39
- concurrent-ruby (1.3.5)
40
- connection_pool (2.5.3)
38
+ concurrent-ruby (1.3.6)
39
+ connection_pool (3.0.2)
41
40
  cork (0.3.0)
42
41
  colored2 (~> 3.1)
43
42
  danger (9.5.3)
@@ -61,14 +60,14 @@ GEM
61
60
  rubocop (~> 1.0)
62
61
  diff-lcs (1.6.2)
63
62
  drb (2.2.3)
64
- faraday (2.13.4)
63
+ faraday (2.14.1)
65
64
  faraday-net_http (>= 2.0, < 3.5)
66
65
  json
67
66
  logger
68
- faraday-http-cache (2.5.1)
67
+ faraday-http-cache (2.6.1)
69
68
  faraday (>= 0.8)
70
- faraday-net_http (3.4.1)
71
- net-http (>= 0.5.0)
69
+ faraday-net_http (3.4.2)
70
+ net-http (~> 0.5)
72
71
  ffi (1.17.2)
73
72
  ffi (1.17.2-arm64-darwin)
74
73
  formatador (1.1.1)
@@ -93,11 +92,11 @@ GEM
93
92
  guard (~> 2.1)
94
93
  guard-compat (~> 1.1)
95
94
  rspec (>= 2.99.0, < 4.0)
96
- i18n (1.14.7)
95
+ i18n (1.14.8)
97
96
  concurrent-ruby (~> 1.0)
98
- json (2.13.2)
99
- kramdown (2.5.1)
100
- rexml (>= 3.3.9)
97
+ json (2.19.3)
98
+ kramdown (2.5.2)
99
+ rexml (>= 3.4.4)
101
100
  kramdown-parser-gfm (1.1.0)
102
101
  kramdown (~> 2.0)
103
102
  language_server-protocol (3.17.0.5)
@@ -108,11 +107,13 @@ GEM
108
107
  logger (1.7.0)
109
108
  lumberjack (1.4.0)
110
109
  method_source (1.1.0)
111
- minitest (5.25.5)
110
+ minitest (6.0.2)
111
+ drb (~> 2.0)
112
+ prism (~> 1.5)
112
113
  nap (1.1.0)
113
114
  nenv (0.3.0)
114
- net-http (0.6.0)
115
- uri
115
+ net-http (0.9.1)
116
+ uri (>= 0.11.1)
116
117
  notiffany (0.1.3)
117
118
  nenv (~> 0.1)
118
119
  shellany (~> 0.0)
@@ -125,22 +126,22 @@ GEM
125
126
  parser (3.3.9.0)
126
127
  ast (~> 2.4.1)
127
128
  racc
128
- prism (1.4.0)
129
+ prism (1.9.0)
129
130
  process_executer (1.3.0)
130
131
  pry (0.15.2)
131
132
  coderay (~> 1.1)
132
133
  method_source (~> 1.0)
133
134
  pstore (0.2.0)
134
- public_suffix (6.0.2)
135
+ public_suffix (7.0.5)
135
136
  racc (1.8.1)
136
137
  rainbow (3.1.1)
137
138
  rake (13.3.0)
138
139
  rb-fsevent (0.11.2)
139
140
  rb-inotify (0.11.1)
140
141
  ffi (~> 1.0)
141
- rchardet (1.9.0)
142
+ rchardet (1.10.0)
142
143
  regexp_parser (2.11.0)
143
- rexml (3.4.2)
144
+ rexml (3.4.4)
144
145
  rspec (3.13.1)
145
146
  rspec-core (~> 3.13.0)
146
147
  rspec-expectations (~> 3.13.0)
@@ -175,7 +176,7 @@ GEM
175
176
  lint_roller (~> 1.1)
176
177
  rubocop (~> 1.72, >= 1.72.1)
177
178
  ruby-progressbar (1.13.0)
178
- sawyer (0.9.2)
179
+ sawyer (0.9.3)
179
180
  addressable (>= 2.3.5)
180
181
  faraday (>= 0.17.3, < 3)
181
182
  securerandom (0.4.1)
@@ -185,11 +186,11 @@ GEM
185
186
  thor (1.4.0)
186
187
  tzinfo (2.0.6)
187
188
  concurrent-ruby (~> 1.0)
188
- unicode-display_width (3.1.4)
189
- unicode-emoji (~> 4.0, >= 4.0.4)
190
- unicode-emoji (4.0.4)
191
- uri (1.0.3)
192
- yard (0.9.37)
189
+ unicode-display_width (3.2.0)
190
+ unicode-emoji (~> 4.1)
191
+ unicode-emoji (4.2.0)
192
+ uri (1.1.1)
193
+ yard (0.9.43)
193
194
 
194
195
  PLATFORMS
195
196
  arm64-darwin-22
data/README.md CHANGED
@@ -71,6 +71,34 @@ my_new_plugin_checker.check_method(param: my_param_value)
71
71
 
72
72
  Please follow the existing naming convention for validation and check plugins: classes end with a `*Checker` suffix and the main validation methods are named with a `check_*` prefix.
73
73
 
74
+ ### How to verify a change against a real pull request
75
+
76
+ Unit tests are the main development loop, but before releasing a new or changed check it's often useful to see it run end-to-end against a real pull request.
77
+ You can do this locally with `danger pr`, which evaluates a `Dangerfile` against an existing PR's diff and prints the result to your terminal only, without posting upstream.
78
+
79
+ Point a `Gemfile` at your branch and add the check you want to exercise to a `Dangerfile`:
80
+
81
+ ```ruby
82
+ # Gemfile
83
+ source 'https://rubygems.org'
84
+ gem 'danger-dangermattic', git: 'https://github.com/Automattic/dangermattic', branch: 'my-branch'
85
+ ```
86
+
87
+ ```ruby
88
+ # Dangerfile
89
+ my_new_plugin_checker.check_method
90
+ ```
91
+
92
+ After a `bundle install`, run it against a PR from inside a checkout of that PR's repository:
93
+
94
+ ```sh
95
+ DANGER_GITHUB_API_TOKEN="$(gh auth token)" \
96
+ BUNDLE_GEMFILE=/path/to/Gemfile \
97
+ bundle exec danger pr https://github.com/<org>/<repo>/pull/<number> \
98
+ --dangerfile=/path/to/Dangerfile
99
+ ```
100
+
101
+ A failure prints as an `Errors:` block. `danger pr` only fetches and creates temporary refs that it cleans up afterwards, so it won't change your current branch or working tree.
74
102
  ## Releasing a new version
75
103
 
76
104
  To create a new release of the Dangermattic gem, use the `new_release` Rake task:
@@ -21,7 +21,7 @@ Gem::Specification.new do |spec|
21
21
 
22
22
  spec.required_ruby_version = '~> 3.2'
23
23
 
24
- spec.add_dependency 'danger', '~> 9.4'
24
+ spec.add_dependency 'danger', '~> 9.5', '>= 9.5.3'
25
25
  spec.add_dependency 'danger-plugin-api', '~> 1.0'
26
26
 
27
27
  # Danger plugins
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dangermattic
4
- VERSION = '1.2.3'
4
+ VERSION = '1.3.0'
5
5
  end
@@ -7,6 +7,10 @@ module Danger
7
7
  #
8
8
  # android_strings_checker.check_strings_do_not_refer_resource
9
9
  #
10
+ # @example Check that existing translatable strings are not modified in place
11
+ #
12
+ # android_strings_checker.check_existing_strings_not_modified
13
+ #
10
14
  # @see Automattic/dangermattic
11
15
  # @tags android, localization
12
16
  #
@@ -14,6 +18,18 @@ module Danger
14
18
  MESSAGE = "This PR adds a translatable entry which references another string resource; this usually causes issues with translations.\n" \
15
19
  'Please make sure to set the `translatable="false"` attribute.'
16
20
 
21
+ STRING_MODIFIED_MESSAGE = 'This PR changes the value of an existing translatable string. Existing string keys must stay immutable so that ' \
22
+ 'in-progress and existing translations remain valid: please add a **new** string key for the new copy instead of ' \
23
+ 'editing the existing one (old translations stay attached to the old key).'
24
+
25
+ # Default file selector: only the app's source (English) strings, living at `…/res/values/strings.xml`.
26
+ # This deliberately excludes localized `values-<locale>/strings.xml` files (whose values legitimately change
27
+ # when translations are pulled in) and any generated/frozen copies that live outside a `res` directory.
28
+ DEFAULT_SOURCE_STRINGS_SELECTOR = ->(path) { path.match?(%r{(^|/)res/values/strings\.xml$}) }
29
+
30
+ # Matches a single-line `<string name="…">value</string>` entry, capturing its name and value.
31
+ STRING_LINE_REGEX = %r{<string\b[^>]*\sname="(?<name>[^"]+)"[^>]*>(?<value>.*)</string>}
32
+
17
33
  # Check if translatable strings reference another string resource in 'strings.xml' files in a pull request.
18
34
  #
19
35
  # @param report_type [Boolean] (optional) Type of report (:error, :warning, :message) whenever a line matches the criteria. Default is :warning.
@@ -27,5 +43,65 @@ module Danger
27
43
  report_type: report_type
28
44
  )
29
45
  end
46
+
47
+ # Check that the value of an existing translatable string is not modified in place.
48
+ #
49
+ # In a continuous-localization setup, source strings flow to the translation system constantly, so changing
50
+ # the English value of an existing key silently invalidates the translations already done for that key. The
51
+ # safe convention is to leave existing keys untouched and introduce a new key for any new copy. This check
52
+ # enforces that convention by failing when a `<string>` entry present in the base is modified (rather than
53
+ # added under a new key). Adding new keys, removing keys, and renaming keys are all allowed.
54
+ #
55
+ # @param report_type [Symbol] (optional) Type of report (:error, :warning, :message). Default is :error.
56
+ # @param file_selector [Proc] (optional) Selects which files to check. Defaults to the app's source
57
+ # `…/res/values/strings.xml` files, excluding localized and generated copies.
58
+ #
59
+ # @return [void]
60
+ def check_existing_strings_not_modified(report_type: :error, file_selector: DEFAULT_SOURCE_STRINGS_SELECTOR)
61
+ files = git_utils.added_and_modified_files.select(&file_selector)
62
+
63
+ files.each do |file|
64
+ diff = danger.git.diff_for_file(file)
65
+ next if diff.nil?
66
+
67
+ removed = translatable_strings_by_name(git_utils.removed_lines(diff_patch: diff.patch))
68
+ added = translatable_strings_by_name(git_utils.added_lines(diff_patch: diff.patch))
69
+
70
+ modified_keys = removed.keys & added.keys
71
+ modified_keys.each do |name|
72
+ next if removed[name].value == added[name].value
73
+
74
+ final_message = <<~MESSAGE
75
+ #{STRING_MODIFIED_MESSAGE}
76
+ File `#{file}`, string `#{name}`:
77
+ ```diff
78
+ -#{removed[name].line.chomp}
79
+ +#{added[name].line.chomp}
80
+ ```
81
+ MESSAGE
82
+
83
+ reporter.report(message: final_message, type: report_type)
84
+ end
85
+ end
86
+ end
87
+
88
+ ParsedString = Struct.new(:value, :line)
89
+
90
+ private
91
+
92
+ # Parses `<string>` entries out of a set of diff content lines, keyed by their `name` attribute.
93
+ # `translatable="false"` entries are ignored, as they are never sent for translation.
94
+ #
95
+ # @param lines [String] Newline-separated content lines (with the diff `+`/`-` markers already stripped).
96
+ #
97
+ # @return [Hash{String => ParsedString}] A mapping of string name to its parsed value and originating line.
98
+ def translatable_strings_by_name(lines)
99
+ lines.each_line.with_object({}) do |line, result|
100
+ next if line.include?('translatable="false"')
101
+
102
+ match = STRING_LINE_REGEX.match(line)
103
+ result[match[:name]] = ParsedString.new(match[:value], line) unless match.nil?
104
+ end
105
+ end
30
106
  end
31
107
  end
@@ -115,13 +115,19 @@ module Danger
115
115
  private
116
116
 
117
117
  def check_manifest_lock_updated(file_name:, lock_file_name:, instruction:, report_type: :warning)
118
+ all_files = git_utils.all_changed_files
119
+
118
120
  # Find all the modified manifest files
119
- manifest_modified_files = git_utils.all_changed_files.select { |f| File.basename(f) == file_name }
121
+ manifest_modified_files = all_files.select { |f| File.basename(f) == file_name }
122
+
123
+ # Build a hash mapping directory -> set of basenames for O(1) lookup
124
+ files_by_dir = all_files.group_by { |f| File.dirname(f) }
125
+ .transform_values { |files| files.to_set { |f| File.basename(f) } }
120
126
 
121
127
  # For each manifest file, check if the corresponding lockfile (in the same dir) was also modified
122
128
  manifest_modified_files.each do |manifest_file|
123
- lockfile_modified = git_utils.all_changed_files.any? { |f| File.dirname(f) == File.dirname(manifest_file) && File.basename(f) == lock_file_name }
124
- next if lockfile_modified
129
+ manifest_dir = File.dirname(manifest_file)
130
+ next if files_by_dir[manifest_dir]&.include?(lock_file_name)
125
131
 
126
132
  message = format(MESSAGE, manifest_file, lock_file_name, instruction)
127
133
  reporter.report(message: message, type: report_type)
@@ -129,11 +135,10 @@ module Danger
129
135
  end
130
136
 
131
137
  def check_manifest_lock_updated_strict(manifest_path:, manifest_lock_path:, instruction:, report_type: :warning)
132
- manifest_modified = git_utils.all_changed_files.include?(manifest_path)
133
- return unless manifest_modified
138
+ all_files_set = git_utils.all_changed_files.to_set
134
139
 
135
- lockfile_modified = git_utils.all_changed_files.include?(manifest_lock_path)
136
- return if lockfile_modified
140
+ return unless all_files_set.include?(manifest_path)
141
+ return if all_files_set.include?(manifest_lock_path)
137
142
 
138
143
  message = format(MESSAGE, manifest_path, File.basename(manifest_lock_path), instruction)
139
144
  reporter.report(message: message, type: report_type)
@@ -76,13 +76,12 @@ module Danger
76
76
  def insertions_size(file_selector: nil)
77
77
  return danger.git.insertions unless file_selector
78
78
 
79
- filtered_files = git_utils.all_changed_files.select(&file_selector)
79
+ # Only check added and modified files - deleted files have 0 insertions
80
+ filtered_files = git_utils.added_and_modified_files.select(&file_selector)
80
81
 
81
82
  filtered_files.sum do |file|
82
- # stats for a file in the GitHub API might be nil, making `info_for_file()` crash
83
- next 0 if danger.git.diff.stats[:files][file].nil?
84
-
85
- danger.git.info_for_file(file)&.[](:insertions).to_i
83
+ # Use cached stats directly instead of calling info_for_file for each file
84
+ danger.git.diff.stats[:files][file]&.[](:insertions).to_i
86
85
  end
87
86
  end
88
87
 
@@ -97,10 +96,8 @@ module Danger
97
96
  filtered_files = git_utils.all_changed_files.select(&file_selector)
98
97
 
99
98
  filtered_files.sum do |file|
100
- # stats for a file in the GitHub API might be nil, making `info_for_file()` crash
101
- next 0 if danger.git.diff.stats[:files][file].nil?
102
-
103
- danger.git.info_for_file(file)&.[](:deletions).to_i
99
+ # Use cached stats directly instead of calling info_for_file for each file
100
+ danger.git.diff.stats[:files][file]&.[](:deletions).to_i
104
101
  end
105
102
  end
106
103
 
@@ -115,10 +112,11 @@ module Danger
115
112
  filtered_files = git_utils.all_changed_files.select(&file_selector)
116
113
 
117
114
  filtered_files.sum do |file|
118
- # stats for a file in the GitHub API might be nil, making `info_for_file()` crash
119
- next 0 if danger.git.diff.stats[:files][file].nil?
115
+ # Use cached stats directly instead of calling info_for_file for each file
116
+ stats = danger.git.diff.stats[:files][file]
117
+ next 0 unless stats
120
118
 
121
- danger.git.info_for_file(file)&.[](:deletions).to_i + danger.git.info_for_file(file)&.[](:insertions).to_i
119
+ stats[:deletions].to_i + stats[:insertions].to_i
122
120
  end
123
121
  end
124
122
  end
@@ -180,6 +180,224 @@ module Danger
180
180
  expect(@dangerfile).to not_report
181
181
  end
182
182
  end
183
+
184
+ context 'when checking that existing strings are not modified' do
185
+ let(:source_strings_xml) { 'WordPress/src/main/res/values/strings.xml' }
186
+
187
+ def stub_diff(path, patch)
188
+ allow(@plugin.git).to receive(:modified_files).and_return([path])
189
+ diff = GitDiffStruct.new('modified', path, patch)
190
+ allow(@plugin.git).to receive(:diff_for_file).with(path).and_return(diff)
191
+ end
192
+
193
+ it 'reports an error when the value of an existing string is modified in place' do
194
+ stub_diff(source_strings_xml, <<~STRINGS)
195
+ diff --git a/#{source_strings_xml} b/#{source_strings_xml}
196
+ index 5794d472..772e2b99 100644
197
+ --- a/#{source_strings_xml}
198
+ +++ b/#{source_strings_xml}
199
+ @@ -1,3 +1,3 @@
200
+ <resources xmlns:tools="http://schemas.android.com/tools">
201
+ - <string name="greeting">Hello</string>
202
+ + <string name="greeting">Hi there</string>
203
+ </resources>
204
+ STRINGS
205
+
206
+ @plugin.check_existing_strings_not_modified
207
+
208
+ expected_error = <<~ERROR
209
+ #{AndroidStringsChecker::STRING_MODIFIED_MESSAGE}
210
+ File `#{source_strings_xml}`, string `greeting`:
211
+ ```diff
212
+ - <string name="greeting">Hello</string>
213
+ + <string name="greeting">Hi there</string>
214
+ ```
215
+ ERROR
216
+
217
+ expect(@dangerfile).to report_errors([expected_error])
218
+ end
219
+
220
+ it 'does nothing when a brand new string key is added' do
221
+ stub_diff(source_strings_xml, <<~STRINGS)
222
+ diff --git a/#{source_strings_xml} b/#{source_strings_xml}
223
+ index 5794d472..772e2b99 100644
224
+ --- a/#{source_strings_xml}
225
+ +++ b/#{source_strings_xml}
226
+ @@ -1,2 +1,3 @@
227
+ <resources xmlns:tools="http://schemas.android.com/tools">
228
+ + <string name="greeting">Hello</string>
229
+ </resources>
230
+ STRINGS
231
+
232
+ @plugin.check_existing_strings_not_modified
233
+
234
+ expect(@dangerfile).to not_report
235
+ end
236
+
237
+ it 'does nothing when an existing string key is removed' do
238
+ stub_diff(source_strings_xml, <<~STRINGS)
239
+ diff --git a/#{source_strings_xml} b/#{source_strings_xml}
240
+ index 5794d472..772e2b99 100644
241
+ --- a/#{source_strings_xml}
242
+ +++ b/#{source_strings_xml}
243
+ @@ -1,3 +1,2 @@
244
+ <resources xmlns:tools="http://schemas.android.com/tools">
245
+ - <string name="greeting">Hello</string>
246
+ </resources>
247
+ STRINGS
248
+
249
+ @plugin.check_existing_strings_not_modified
250
+
251
+ expect(@dangerfile).to not_report
252
+ end
253
+
254
+ it 'does nothing when a string is only reordered (value unchanged)' do
255
+ stub_diff(source_strings_xml, <<~STRINGS)
256
+ diff --git a/#{source_strings_xml} b/#{source_strings_xml}
257
+ index 5794d472..772e2b99 100644
258
+ --- a/#{source_strings_xml}
259
+ +++ b/#{source_strings_xml}
260
+ @@ -1,4 +1,4 @@
261
+ <resources xmlns:tools="http://schemas.android.com/tools">
262
+ - <string name="greeting">Hello</string>
263
+ <string name="other">Other</string>
264
+ + <string name="greeting">Hello</string>
265
+ </resources>
266
+ STRINGS
267
+
268
+ @plugin.check_existing_strings_not_modified
269
+
270
+ expect(@dangerfile).to not_report
271
+ end
272
+
273
+ it 'does nothing when a key is renamed (old key removed, new key added)' do
274
+ stub_diff(source_strings_xml, <<~STRINGS)
275
+ diff --git a/#{source_strings_xml} b/#{source_strings_xml}
276
+ index 5794d472..772e2b99 100644
277
+ --- a/#{source_strings_xml}
278
+ +++ b/#{source_strings_xml}
279
+ @@ -1,3 +1,3 @@
280
+ <resources xmlns:tools="http://schemas.android.com/tools">
281
+ - <string name="greeting">Hello</string>
282
+ + <string name="greeting_v2">Hello</string>
283
+ </resources>
284
+ STRINGS
285
+
286
+ @plugin.check_existing_strings_not_modified
287
+
288
+ expect(@dangerfile).to not_report
289
+ end
290
+
291
+ it 'ignores modifications to non-translatable strings' do
292
+ stub_diff(source_strings_xml, <<~STRINGS)
293
+ diff --git a/#{source_strings_xml} b/#{source_strings_xml}
294
+ index 5794d472..772e2b99 100644
295
+ --- a/#{source_strings_xml}
296
+ +++ b/#{source_strings_xml}
297
+ @@ -1,3 +1,3 @@
298
+ <resources xmlns:tools="http://schemas.android.com/tools">
299
+ - <string name="app_name" translatable="false">WordPress</string>
300
+ + <string name="app_name" translatable="false">WordPress.com</string>
301
+ </resources>
302
+ STRINGS
303
+
304
+ @plugin.check_existing_strings_not_modified
305
+
306
+ expect(@dangerfile).to not_report
307
+ end
308
+
309
+ it 'ignores localized strings.xml files by default' do
310
+ localized_path = 'WordPress/src/main/res/values-fr/strings.xml'
311
+ stub_diff(localized_path, <<~STRINGS)
312
+ diff --git a/#{localized_path} b/#{localized_path}
313
+ index 5794d472..772e2b99 100644
314
+ --- a/#{localized_path}
315
+ +++ b/#{localized_path}
316
+ @@ -1,3 +1,3 @@
317
+ <resources xmlns:tools="http://schemas.android.com/tools">
318
+ - <string name="greeting">Bonjour</string>
319
+ + <string name="greeting">Salut</string>
320
+ </resources>
321
+ STRINGS
322
+
323
+ @plugin.check_existing_strings_not_modified
324
+
325
+ expect(@dangerfile).to not_report
326
+ end
327
+
328
+ it 'reports an error for each modified string across multiple source files' do
329
+ wp_path = 'WordPress/src/main/res/values/strings.xml'
330
+ jp_path = 'WordPress/src/jetpack/res/values/strings.xml'
331
+ allow(@plugin.git).to receive(:modified_files).and_return([wp_path, jp_path])
332
+
333
+ wp_diff = GitDiffStruct.new('modified', wp_path, <<~STRINGS)
334
+ diff --git a/#{wp_path} b/#{wp_path}
335
+ index 5794d472..772e2b99 100644
336
+ --- a/#{wp_path}
337
+ +++ b/#{wp_path}
338
+ @@ -1,3 +1,3 @@
339
+ <resources xmlns:tools="http://schemas.android.com/tools">
340
+ - <string name="greeting">Hello</string>
341
+ + <string name="greeting">Hi</string>
342
+ </resources>
343
+ STRINGS
344
+ allow(@plugin.git).to receive(:diff_for_file).with(wp_path).and_return(wp_diff)
345
+
346
+ jp_diff = GitDiffStruct.new('modified', jp_path, <<~STRINGS)
347
+ diff --git a/#{jp_path} b/#{jp_path}
348
+ index 5794d472..772e2b99 100644
349
+ --- a/#{jp_path}
350
+ +++ b/#{jp_path}
351
+ @@ -1,3 +1,3 @@
352
+ <resources xmlns:tools="http://schemas.android.com/tools">
353
+ - <string name="farewell">Bye</string>
354
+ + <string name="farewell">Goodbye</string>
355
+ </resources>
356
+ STRINGS
357
+ allow(@plugin.git).to receive(:diff_for_file).with(jp_path).and_return(jp_diff)
358
+
359
+ @plugin.check_existing_strings_not_modified
360
+
361
+ wp_error = <<~ERROR
362
+ #{AndroidStringsChecker::STRING_MODIFIED_MESSAGE}
363
+ File `#{wp_path}`, string `greeting`:
364
+ ```diff
365
+ - <string name="greeting">Hello</string>
366
+ + <string name="greeting">Hi</string>
367
+ ```
368
+ ERROR
369
+
370
+ jp_error = <<~ERROR
371
+ #{AndroidStringsChecker::STRING_MODIFIED_MESSAGE}
372
+ File `#{jp_path}`, string `farewell`:
373
+ ```diff
374
+ - <string name="farewell">Bye</string>
375
+ + <string name="farewell">Goodbye</string>
376
+ ```
377
+ ERROR
378
+
379
+ expect(@dangerfile.status_report[:errors]).to contain_exactly(wp_error, jp_error)
380
+ end
381
+
382
+ it 'can be configured to report a warning instead of an error' do
383
+ stub_diff(source_strings_xml, <<~STRINGS)
384
+ diff --git a/#{source_strings_xml} b/#{source_strings_xml}
385
+ index 5794d472..772e2b99 100644
386
+ --- a/#{source_strings_xml}
387
+ +++ b/#{source_strings_xml}
388
+ @@ -1,3 +1,3 @@
389
+ <resources xmlns:tools="http://schemas.android.com/tools">
390
+ - <string name="greeting">Hello</string>
391
+ + <string name="greeting">Hi there</string>
392
+ </resources>
393
+ STRINGS
394
+
395
+ @plugin.check_existing_strings_not_modified(report_type: :warning)
396
+
397
+ expect(@dangerfile.status_report[:warnings].count).to eq(1)
398
+ expect(@dangerfile.status_report[:errors]).to be_empty
399
+ end
400
+ end
183
401
  end
184
402
  end
185
403
  end
@@ -146,19 +146,20 @@ module Danger
146
146
  allow(@plugin.git).to receive_messages(added_files: [added_config, added_file], modified_files: [modified_file1, modified_file2, added_test_file, modified_strings], deleted_files: [deleted_file1, deleted_test_file, deleted_strings, deleted_file2])
147
147
 
148
148
  allow(@plugin.git).to receive(:diff).and_return(instance_double(Git::Diff))
149
- expected_files = { added_test_file => {}, added_config => {}, added_file => {}, modified_file1 => {}, modified_file2 => {}, modified_strings => {}, deleted_file1 => {}, deleted_file2 => {}, deleted_test_file => {}, deleted_strings => {} }
149
+ # Populate stats hash directly with insertions/deletions data for the optimized code path
150
+ expected_files = {
151
+ added_test_file => { insertions: 201 },
152
+ added_config => { insertions: 311 },
153
+ added_file => { insertions: 13 },
154
+ modified_file1 => { insertions: 127, deletions: 159 },
155
+ modified_file2 => { insertions: 43, deletions: 37 },
156
+ modified_strings => { insertions: 432, deletions: 297 },
157
+ deleted_file1 => { deletions: 246 },
158
+ deleted_file2 => { deletions: 493 },
159
+ deleted_test_file => { deletions: 222 },
160
+ deleted_strings => { deletions: 593 }
161
+ }
150
162
  allow(@plugin.git.diff).to receive(:stats).and_return({ files: expected_files })
151
-
152
- allow(@plugin.git).to receive(:info_for_file).with(added_test_file).and_return({ insertions: 201 })
153
- allow(@plugin.git).to receive(:info_for_file).with(added_config).and_return({ insertions: 311 })
154
- allow(@plugin.git).to receive(:info_for_file).with(added_file).and_return({ insertions: 13 })
155
- allow(@plugin.git).to receive(:info_for_file).with(modified_file1).and_return({ insertions: 127, deletions: 159 })
156
- allow(@plugin.git).to receive(:info_for_file).with(modified_file2).and_return({ insertions: 43, deletions: 37 })
157
- allow(@plugin.git).to receive(:info_for_file).with(modified_strings).and_return({ insertions: 432, deletions: 297 })
158
- allow(@plugin.git).to receive(:info_for_file).with(deleted_file1).and_return({ deletions: 246 })
159
- allow(@plugin.git).to receive(:info_for_file).with(deleted_file2).and_return({ deletions: 493 })
160
- allow(@plugin.git).to receive(:info_for_file).with(deleted_test_file).and_return({ deletions: 222 })
161
- allow(@plugin.git).to receive(:info_for_file).with(deleted_strings).and_return({ deletions: 593 })
162
163
  end
163
164
  end
164
165
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: danger-dangermattic
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Automattic
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-09-25 00:00:00.000000000 Z
11
+ date: 2026-06-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: danger
@@ -16,14 +16,20 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '9.4'
19
+ version: '9.5'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 9.5.3
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
27
  - - "~>"
25
28
  - !ruby/object:Gem::Version
26
- version: '9.4'
29
+ version: '9.5'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 9.5.3
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: danger-plugin-api
29
35
  requirement: !ruby/object:Gem::Requirement
@@ -201,6 +207,7 @@ files:
201
207
  - ".buildkite/gem-push.sh"
202
208
  - ".buildkite/pipeline.yml"
203
209
  - ".bundle/config"
210
+ - ".github/dependabot.yml"
204
211
  - ".github/workflows/README.md"
205
212
  - ".github/workflows/reusable-check-labels-on-issues.yml"
206
213
  - ".github/workflows/reusable-retry-buildkite-step-on-events.yml"
@@ -209,7 +216,9 @@ files:
209
216
  - ".rubocop.yml"
210
217
  - ".ruby-version"
211
218
  - ".yardopts"
219
+ - AGENTS.md
212
220
  - CHANGELOG.md
221
+ - CLAUDE.md
213
222
  - Gemfile
214
223
  - Gemfile.lock
215
224
  - Guardfile