|
1 | | -# .github/workflows/languagetool.yml |
2 | | -name: LanguageTool (PR comment) |
3 | | - |
4 | | -on: |
5 | | - pull_request_target: |
6 | | - types: [opened, reopened, synchronize, labeled] |
7 | | - issue_comment: |
8 | | - types: [created] |
9 | | - |
10 | | -permissions: |
11 | | - contents: read |
12 | | - pull-requests: write |
13 | | - issues: write |
14 | | - |
15 | | -concurrency: |
16 | | - group: languagetool-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} |
17 | | - cancel-in-progress: true |
18 | | - |
19 | | -env: |
20 | | - LT_LANGUAGE: en-US |
21 | | - RERUN_LABEL: languagetool:rerun |
22 | | - |
| 1 | +name: reviewdog |
| 2 | +on: [pull_request] |
23 | 3 | jobs: |
24 | | - # Comment command -> toggles a label to trigger the PR job |
25 | | - rerun_on_comment: |
26 | | - if: | |
27 | | - github.event_name == 'issue_comment' && |
28 | | - github.event.issue.pull_request && |
29 | | - startsWith(github.event.comment.body, '/languagetool') && |
30 | | - (github.event.comment.author_association == 'MEMBER' || |
31 | | - github.event.comment.author_association == 'OWNER' || |
32 | | - github.event.comment.author_association == 'COLLABORATOR') |
| 4 | + linter_name: |
| 5 | + name: LanguageTool grammar check |
33 | 6 | runs-on: ubuntu-latest |
34 | 7 | steps: |
35 | | - - name: Toggle rerun label on PR |
36 | | - uses: actions/github-script@v7 |
37 | | - with: |
38 | | - script: | |
39 | | - const owner = context.repo.owner; |
40 | | - const repo = context.repo.repo; |
41 | | - const issue_number = context.issue.number; |
42 | | - const label = process.env.RERUN_LABEL; |
43 | | -
|
44 | | - // Ensure label exists |
45 | | - try { |
46 | | - await github.rest.issues.getLabel({ owner, repo, name: label }); |
47 | | - } catch { |
48 | | - await github.rest.issues.createLabel({ |
49 | | - owner, repo, name: label, color: '0e8a16', |
50 | | - description: 'Rerun LanguageTool on this PR' |
51 | | - }); |
52 | | - } |
53 | | -
|
54 | | - // Remove if present (ignore errors), then add to force a new "labeled" event |
55 | | - try { |
56 | | - await github.rest.issues.removeLabel({ owner, repo, issue_number, name: label }); |
57 | | - } catch {} |
58 | | - await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [label] }); |
59 | | -
|
60 | | - languagetool: |
61 | | - if: | |
62 | | - github.event_name == 'pull_request_target' && |
63 | | - ( |
64 | | - github.event.action == 'opened' || |
65 | | - github.event.action == 'reopened' || |
66 | | - github.event.action == 'synchronize' || |
67 | | - (github.event.action == 'labeled' && github.event.label.name == 'languagetool:rerun') |
68 | | - ) |
69 | | - runs-on: ubuntu-latest |
70 | | - |
71 | | - steps: |
72 | | - - name: Checkout PR head (safe) |
73 | | - uses: actions/checkout@v4 |
74 | | - with: |
75 | | - repository: ${{ github.event.pull_request.head.repo.full_name }} |
76 | | - ref: ${{ github.event.pull_request.head.sha }} |
77 | | - fetch-depth: 0 |
78 | | - persist-credentials: false |
79 | | - |
80 | | - - name: Fetch base SHA for diff |
81 | | - run: | |
82 | | - set -euo pipefail |
83 | | - git remote add upstream "https://github.com/${{ github.event.pull_request.base.repo.full_name }}.git" || true |
84 | | - git fetch --no-tags --depth=1 upstream "${{ github.event.pull_request.base.sha }}" |
85 | | -
|
86 | | - - name: Setup Java 17 |
87 | | - uses: actions/setup-java@v4 |
88 | | - with: |
89 | | - distribution: temurin |
90 | | - java-version: "17" |
91 | | - |
92 | | - - name: Download LanguageTool CLI (latest snapshot) |
93 | | - run: | |
94 | | - set -euo pipefail |
95 | | - curl -fsSL -o lt.zip "https://internal1.languagetool.org/snapshots/LanguageTool-latest-snapshot.zip" |
96 | | - rm -rf .lt |
97 | | - mkdir -p .lt |
98 | | - unzip -q lt.zip -d .lt |
99 | | -
|
100 | | - LT_JAR="$(ls -1 .lt/**/languagetool-commandline.jar 2>/dev/null | head -n1 || true)" |
101 | | - if [ -z "${LT_JAR}" ]; then |
102 | | - echo "Could not find languagetool-commandline.jar in snapshot" >&2 |
103 | | - find .lt -maxdepth 4 -type f -name "*languagetool*jar" -print >&2 || true |
104 | | - exit 1 |
105 | | - fi |
106 | | -
|
107 | | - echo "LT_JAR=${LT_JAR}" >> "$GITHUB_ENV" |
108 | | -
|
109 | | - - name: Run LanguageTool + build PR comment (collapsible + exact word) |
110 | | - env: |
111 | | - BASE_SHA: ${{ github.event.pull_request.base.sha }} |
112 | | - HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
113 | | - run: | |
114 | | - set -euo pipefail |
115 | | -
|
116 | | - # jq is present on ubuntu-latest, but install if your runner image differs |
117 | | - command -v jq >/dev/null || (sudo apt-get update && sudo apt-get install -y jq) |
118 | | -
|
119 | | - # Choose files to check |
120 | | - mapfile -t FILES < <(git diff --name-only "$BASE_SHA" "$HEAD_SHA" \ |
121 | | - | grep -E '\.(md|mdx|txt|rst|adoc|asciidoc|tex)$' || true) |
122 | | -
|
123 | | - # Load custom words (optional) |
124 | | - WORDS_FILE=".languagetool/words.txt" |
125 | | - if [ -f "$WORDS_FILE" ]; then |
126 | | - WORDS_JSON="$(jq -R -s ' |
127 | | - split("\n") |
128 | | - | map(gsub("\r";"")) |
129 | | - | map(select(length>0 and (startswith("#")|not))) |
130 | | - | map(ascii_downcase) |
131 | | - ' "$WORDS_FILE")" |
132 | | - else |
133 | | - WORDS_JSON='[]' |
134 | | - fi |
135 | | -
|
136 | | - : > results.jsonl |
137 | | -
|
138 | | - if [ "${#FILES[@]}" -eq 0 ]; then |
139 | | - echo '{"file":"(none)","issues":[]}' >> results.jsonl |
140 | | - else |
141 | | - for f in "${FILES[@]}"; do |
142 | | - [ -f "$f" ] || continue |
143 | | -
|
144 | | - # LT can print banner lines; keep JSON only (accepts either { or [) |
145 | | - java -jar "$LT_JAR" -l "${LT_LANGUAGE}" --json "$f" 2>/dev/null \ |
146 | | - | sed -n '/^[{[]/,$p' > lt.json || true |
147 | | -
|
148 | | - # Extract issues and filter spelling-ish matches for custom words |
149 | | - jq -c \ |
150 | | - --arg file "$f" \ |
151 | | - --argjson words "$WORDS_JSON" ' |
152 | | - def bad_raw: |
153 | | - (.context.offset // 0) as $o |
154 | | - | (.context.length // 0) as $l |
155 | | - | (.context.text // "") as $t |
156 | | - | ($t[$o:($o+$l)]); |
157 | | -
|
158 | | - def badtoken: |
159 | | - (bad_raw |
160 | | - | gsub("^[^[:alnum:]]+|[^[:alnum:]]+$";"") |
161 | | - | ascii_downcase); |
162 | | -
|
163 | | - (.matches // []) |
164 | | - | map( |
165 | | - . as $m |
166 | | - | ($m.rule.id // "") as $rid |
167 | | - | ($m.rule.category.id // "") as $cat |
168 | | - | (badtoken) as $bt |
169 | | - | select( |
170 | | - ( (( $cat == "TYPOS") or ($rid|test("MORFOLOGIK";"i")) ) |
171 | | - and (($words|index($bt)) != null) |
172 | | - ) | not |
173 | | - ) |
174 | | - | { |
175 | | - message: ($m.message // "LanguageTool finding"), |
176 | | - rule: $rid, |
177 | | - bad: (bad_raw), |
178 | | - replacements: (($m.replacements // []) | map(.value) | .[0:3]), |
179 | | - context: ($m.context.text // ""), |
180 | | - context_offset: ($m.context.offset // 0), |
181 | | - context_length: ($m.context.length // 0) |
182 | | - } |
183 | | - ) |
184 | | - | {file:$file, issues:.} |
185 | | - ' lt.json >> results.jsonl |
186 | | - done |
187 | | - fi |
188 | | -
|
189 | | - # Build markdown body (stored as a file) - collapsible per file |
190 | | - node <<'NODE' |
191 | | - const fs = require("fs"); |
192 | | -
|
193 | | - const marker = "<!-- languagetool-report -->"; |
194 | | - const raw = fs.readFileSync("results.jsonl","utf8").trim(); |
195 | | - const lines = raw ? raw.split("\n").filter(Boolean) : []; |
196 | | - const parsed = lines.map(l => JSON.parse(l)); |
197 | | -
|
198 | | - const checkedFiles = parsed.map(p => p.file).filter(f => f && f !== "(none)"); |
199 | | - const byFile = parsed |
200 | | - .filter(p => Array.isArray(p.issues) && p.issues.length > 0) |
201 | | - .reduce((acc, p) => { acc[p.file] = p.issues; return acc; }, {}); |
202 | | -
|
203 | | - let total = 0; |
204 | | - for (const f of Object.keys(byFile)) total += byFile[f].length; |
205 | | -
|
206 | | - function inlineCode(s) { |
207 | | - if (s == null) return ""; |
208 | | - return String(s).replace(/`/g, "\\`").replace(/\n/g, " ").trim(); |
209 | | - } |
210 | | -
|
211 | | - function shortContext(text, maxLen=220) { |
212 | | - const t = (text || "").replace(/\s+/g, " ").trim(); |
213 | | - if (t.length <= maxLen) return t; |
214 | | - return t.slice(0, maxLen - 1) + "…"; |
215 | | - } |
216 | | -
|
217 | | - let body = |
218 | | - `${marker} |
219 | | - ## LanguageTool report |
220 | | -
|
221 | | - **Language:** \`${process.env.LT_LANGUAGE || "en-US"}\` |
222 | | - **Checked files:** ${checkedFiles.length} |
223 | | - **Findings:** ${total} |
224 | | - `; |
225 | | -
|
226 | | - if (!checkedFiles.length) { |
227 | | - body += `\nNo supported text files changed in this PR (based on configured extensions).\n`; |
228 | | - } else if (total === 0) { |
229 | | - body += `\n✅ No issues found in the changed text files.\n`; |
230 | | - } else { |
231 | | - body += `\n---\n`; |
232 | | - for (const [file, issues] of Object.entries(byFile)) { |
233 | | - body += `\n<details>\n<summary><strong>${file}</strong> — ${issues.length} finding(s)</summary>\n\n`; |
234 | | - for (const it of issues.slice(0, 200)) { |
235 | | - const found = inlineCode(it.bad); |
236 | | - const ctx = shortContext(it.context); |
237 | | - const sug = (it.replacements && it.replacements.length) |
238 | | - ? `Suggested: ${it.replacements.map(s => `\`${inlineCode(s)}\``).join(", ")}\n` |
239 | | - : ""; |
240 | | - body += |
241 | | - `- **${inlineCode(it.rule || "RULE")}**: ${inlineCode(it.message)} |
242 | | - - Found: \`${found}\` |
243 | | - - ${sug ? sug.trimEnd() : "Suggested: (none)"} |
244 | | - - Context: ${ctx ? `> ${ctx}` : "(none)"} |
245 | | -
|
246 | | - `; |
247 | | - } |
248 | | - if (issues.length > 200) body += `…(${issues.length - 200} more in this file)\n\n`; |
249 | | - body += `</details>\n`; |
250 | | - } |
251 | | - } |
252 | | -
|
253 | | - fs.writeFileSync("comment.md", body.trim() + "\n"); |
254 | | - NODE |
255 | | -
|
256 | | - - name: Post or update PR comment |
257 | | - uses: actions/github-script@v7 |
258 | | - with: |
259 | | - script: | |
260 | | - const fs = require('fs'); |
261 | | - const owner = context.repo.owner; |
262 | | - const repo = context.repo.repo; |
263 | | - const issue_number = context.payload.pull_request.number; |
264 | | -
|
265 | | - const body = fs.readFileSync('comment.md', 'utf8'); |
266 | | - const marker = '<!-- languagetool-report -->'; |
267 | | -
|
268 | | - const { data: comments } = await github.rest.issues.listComments({ |
269 | | - owner, repo, issue_number, per_page: 100 |
270 | | - }); |
271 | | -
|
272 | | - const existing = comments.find(c => (c.body || '').includes(marker)); |
273 | | -
|
274 | | - if (existing) { |
275 | | - await github.rest.issues.updateComment({ |
276 | | - owner, repo, comment_id: existing.id, body |
277 | | - }); |
278 | | - } else { |
279 | | - await github.rest.issues.createComment({ |
280 | | - owner, repo, issue_number, body |
281 | | - }); |
282 | | - } |
283 | | -
|
284 | | - - name: Remove rerun label |
285 | | - if: github.event.action == 'labeled' && github.event.label.name == 'languagetool:rerun' |
286 | | - continue-on-error: true |
287 | | - uses: actions/github-script@v7 |
288 | | - with: |
289 | | - script: | |
290 | | - await github.rest.issues.removeLabel({ |
291 | | - owner: context.repo.owner, |
292 | | - repo: context.repo.repo, |
293 | | - issue_number: context.payload.pull_request.number, |
294 | | - name: process.env.RERUN_LABEL, |
295 | | - }); |
| 8 | + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 |
| 9 | + - uses: reviewdog/action-languagetool@ea19c757470ce0dbfcbc34aec090317cef1ff0b5 # v1.22.0 |
| 10 | + with: |
| 11 | + github_token: ${{ secrets.github_token }} |
| 12 | + # Change reviewdog reporter if you need [github-pr-check,github-check,github-pr-review]. |
| 13 | + reporter: github-pr-review |
| 14 | + # Change reporter level if you need. |
| 15 | + level: info |
0 commit comments