Skip to content

Commit 2ed97eb

Browse files
authored
Create summary comment for lintcheck runs (#14816)
Previously rust-lang/triagebot#1985 After a lintcheck run a comment will be created in the PR thread with an overview of the changes, example here Alexendoo/rust-clippy#18 (comment) (plus the normal GHA debugging experience) It will only comment if there are some changes, if there's already an existing comment it will be updated for each run Similar to https://github.com/rust-lang/team/blob/master/.github/workflows/dry-run.yml The PR number is supplied by the lintcheck run, so technically someone could forge it to be annoying and edit the summaries in other threads, but that is pretty low impact and easy to deal with. There is a `pull_requests` field on the event but as @Kobzol [pointed out to me](rust-lang/triagebot#1985 (comment)) it's not populated for PRs from forks r? @flip1995 changelog: none
2 parents d964e55 + 2dbaf1c commit 2ed97eb

File tree

5 files changed

+200
-49
lines changed

5 files changed

+200
-49
lines changed

.github/workflows/lintcheck.yml

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -128,21 +128,27 @@ jobs:
128128
- name: Download JSON
129129
uses: actions/download-artifact@v4
130130

131+
- name: Store PR number
132+
run: echo ${{ github.event.pull_request.number }} > pr.txt
133+
131134
- name: Diff results
132-
# GH's summery has a maximum size of 1024k:
133-
# https://docs.github.com/actions/using-workflows/workflow-commands-for-github-actions#adding-a-markdown-summary
134-
# That's why we first log to file and then to the summary and logs
135+
# GH's summery has a maximum size of 1MiB:
136+
# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#step-isolation-and-limits
137+
# We upload the full diff as an artifact in case it's truncated
135138
run: |
136-
./target/debug/lintcheck diff {base,head}/ci_crates_logs.json --truncate >> truncated_diff.md
137-
head -c 1024000 truncated_diff.md >> $GITHUB_STEP_SUMMARY
138-
cat truncated_diff.md
139-
./target/debug/lintcheck diff {base,head}/ci_crates_logs.json >> full_diff.md
139+
./target/debug/lintcheck diff {base,head}/ci_crates_logs.json --truncate | head -c 1M > $GITHUB_STEP_SUMMARY
140+
./target/debug/lintcheck diff {base,head}/ci_crates_logs.json --write-summary summary.json > full_diff.md
140141
141142
- name: Upload full diff
142143
uses: actions/upload-artifact@v4
143144
with:
144-
name: diff
145-
if-no-files-found: ignore
145+
name: full_diff
146+
path: full_diff.md
147+
148+
- name: Upload summary
149+
uses: actions/upload-artifact@v4
150+
with:
151+
name: summary
146152
path: |
147-
full_diff.md
148-
truncated_diff.md
153+
summary.json
154+
pr.txt
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
name: Lintcheck summary
2+
3+
# The workflow_run event runs in the context of the Clippy repo giving it write
4+
# access, needed here to create a PR comment when the PR originates from a fork
5+
#
6+
# The summary artifact is a JSON file that we verify in this action to prevent
7+
# the creation of arbitrary comments
8+
#
9+
# This action must not checkout/run code from the originating workflow_run
10+
# or directly interpolate ${{}} untrusted fields into code
11+
#
12+
# https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#workflow_run
13+
# https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections
14+
15+
on:
16+
workflow_run:
17+
workflows: [Lintcheck]
18+
types: [completed]
19+
20+
# Restrict the default permission scope https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#defining-access-for-the-github_token-scopes
21+
permissions:
22+
pull-requests: write
23+
24+
jobs:
25+
download:
26+
runs-on: ubuntu-latest
27+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
28+
steps:
29+
- name: Download artifact
30+
uses: actions/download-artifact@v4
31+
with:
32+
name: summary
33+
path: untrusted
34+
run-id: ${{ github.event.workflow_run.id }}
35+
github-token: ${{ github.token }}
36+
37+
- name: Format comment
38+
uses: actions/github-script@v7
39+
with:
40+
script: |
41+
const fs = require("fs");
42+
const assert = require("assert/strict");
43+
44+
function validateName(s) {
45+
assert.match(s, /^[a-z0-9_:]+$/);
46+
return s;
47+
}
48+
49+
function validateInt(i) {
50+
assert.ok(Number.isInteger(i));
51+
return i;
52+
}
53+
54+
function tryReadSummary() {
55+
try {
56+
return JSON.parse(fs.readFileSync("untrusted/summary.json"));
57+
} catch {
58+
return null;
59+
}
60+
}
61+
62+
const prNumber = parseInt(fs.readFileSync("untrusted/pr.txt"), 10);
63+
core.exportVariable("PR", prNumber.toString());
64+
65+
const untrustedSummary = tryReadSummary();
66+
if (!Array.isArray(untrustedSummary)) {
67+
return;
68+
}
69+
70+
let summary = `Lintcheck changes for ${context.payload.workflow_run.head_sha}
71+
72+
| Lint | Added | Removed | Changed |
73+
| ---- | ----: | ------: | ------: |
74+
`;
75+
76+
for (const untrustedRow of untrustedSummary) {
77+
const name = validateName(untrustedRow.name);
78+
79+
const added = validateInt(untrustedRow.added);
80+
const removed = validateInt(untrustedRow.removed);
81+
const changed = validateInt(untrustedRow.changed);
82+
83+
const id = name.replace("clippy::", "user-content-").replaceAll("_", "-");
84+
const url = `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${context.payload.workflow_run.id}#${id}`;
85+
86+
summary += `| [\`${name}\`](${url}) | ${added} | ${removed} | ${changed} |\n`;
87+
}
88+
89+
summary += "\nThis comment will be updated if you push new changes";
90+
91+
fs.writeFileSync("summary.md", summary);
92+
93+
- name: Create/update comment
94+
run: |
95+
if [[ -f summary.md ]]; then
96+
gh pr comment "$PR" --body-file summary.md --edit-last --create-if-none
97+
else
98+
# There were no changes detected by Lintcheck
99+
# - If a comment exists from a previous run that did detect changes, edit it (--edit-last)
100+
# - If no comment exists do not create one, the `gh` command exits with an error which
101+
# `|| true` ignores
102+
gh pr comment "$PR" --body "No changes for ${{ github.event.workflow_run.head_sha }}" --edit-last || true
103+
fi
104+
env:
105+
GH_TOKEN: ${{ github.token }}
106+
GH_REPO: ${{ github.repository }}

lintcheck/src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ pub(crate) enum Commands {
6868
/// This will limit the number of warnings that will be printed for each lint
6969
#[clap(long)]
7070
truncate: bool,
71+
/// Write the diff summary to a JSON file if there are any changes
72+
#[clap(long, value_name = "PATH")]
73+
write_summary: Option<PathBuf>,
7174
},
7275
/// Create a lintcheck crates TOML file containing the top N popular crates
7376
Popular {

lintcheck/src/json.rs

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
//! loading warnings from JSON files, and generating human-readable diffs
55
//! between different linting runs.
66
7-
use std::fs;
8-
use std::path::Path;
7+
use std::path::{Path, PathBuf};
8+
use std::{fmt, fs};
99

1010
use itertools::{EitherOrBoth, Itertools};
1111
use serde::{Deserialize, Serialize};
@@ -17,7 +17,6 @@ const DEFAULT_LIMIT_PER_LINT: usize = 300;
1717
/// Target for total warnings to display across all lints when truncating output.
1818
const TRUNCATION_TOTAL_TARGET: usize = 1000;
1919

20-
/// Representation of a single Clippy warning for JSON serialization.
2120
#[derive(Debug, Deserialize, Serialize)]
2221
struct LintJson {
2322
/// The lint name e.g. `clippy::bytes_nth`
@@ -29,7 +28,6 @@ struct LintJson {
2928
}
3029

3130
impl LintJson {
32-
/// Returns a tuple of name and `file_line` for sorting and comparison.
3331
fn key(&self) -> impl Ord + '_ {
3432
(self.name.as_str(), self.file_line.as_str())
3533
}
@@ -40,6 +38,57 @@ impl LintJson {
4038
}
4139
}
4240

41+
#[derive(Debug, Serialize)]
42+
struct SummaryRow {
43+
name: String,
44+
added: usize,
45+
removed: usize,
46+
changed: usize,
47+
}
48+
49+
#[derive(Debug, Serialize)]
50+
struct Summary(Vec<SummaryRow>);
51+
52+
impl fmt::Display for Summary {
53+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54+
f.write_str(
55+
"\
56+
| Lint | Added | Removed | Changed |
57+
| ---- | ----: | ------: | ------: |
58+
",
59+
)?;
60+
61+
for SummaryRow {
62+
name,
63+
added,
64+
changed,
65+
removed,
66+
} in &self.0
67+
{
68+
let html_id = to_html_id(name);
69+
writeln!(f, "| [`{name}`](#{html_id}) | {added} | {changed} | {removed} |")?;
70+
}
71+
72+
Ok(())
73+
}
74+
}
75+
76+
impl Summary {
77+
fn new(lints: &[LintWarnings]) -> Self {
78+
Summary(
79+
lints
80+
.iter()
81+
.map(|lint| SummaryRow {
82+
name: lint.name.clone(),
83+
added: lint.added.len(),
84+
removed: lint.removed.len(),
85+
changed: lint.changed.len(),
86+
})
87+
.collect(),
88+
)
89+
}
90+
}
91+
4392
/// Creates the log file output for [`crate::config::OutputFormat::Json`]
4493
pub(crate) fn output(clippy_warnings: Vec<ClippyWarning>) -> String {
4594
let mut lints: Vec<LintJson> = clippy_warnings
@@ -74,7 +123,7 @@ fn load_warnings(path: &Path) -> Vec<LintJson> {
74123
///
75124
/// Compares warnings from `old_path` and `new_path`, then displays a summary table
76125
/// and detailed information about added, removed, and changed warnings.
77-
pub(crate) fn diff(old_path: &Path, new_path: &Path, truncate: bool) {
126+
pub(crate) fn diff(old_path: &Path, new_path: &Path, truncate: bool, write_summary: Option<PathBuf>) {
78127
let old_warnings = load_warnings(old_path);
79128
let new_warnings = load_warnings(new_path);
80129

@@ -108,13 +157,16 @@ pub(crate) fn diff(old_path: &Path, new_path: &Path, truncate: bool) {
108157
}
109158
}
110159

111-
print_summary_table(&lint_warnings);
112-
println!();
113-
114160
if lint_warnings.is_empty() {
115161
return;
116162
}
117163

164+
let summary = Summary::new(&lint_warnings);
165+
if let Some(path) = write_summary {
166+
let json = serde_json::to_string(&summary).unwrap();
167+
fs::write(path, json).unwrap();
168+
}
169+
118170
let truncate_after = if truncate {
119171
// Max 15 ensures that we at least have five messages per lint
120172
DEFAULT_LIMIT_PER_LINT
@@ -126,6 +178,7 @@ pub(crate) fn diff(old_path: &Path, new_path: &Path, truncate: bool) {
126178
usize::MAX
127179
};
128180

181+
println!("{summary}");
129182
for lint in lint_warnings {
130183
print_lint_warnings(&lint, truncate_after);
131184
}
@@ -140,13 +193,11 @@ struct LintWarnings {
140193
changed: Vec<(LintJson, LintJson)>,
141194
}
142195

143-
/// Prints a formatted report for a single lint type with its warnings.
144196
fn print_lint_warnings(lint: &LintWarnings, truncate_after: usize) {
145197
let name = &lint.name;
146198
let html_id = to_html_id(name);
147199

148-
// The additional anchor is added for non GH viewers that don't prefix ID's
149-
println!(r#"## `{name}` <a id="user-content-{html_id}"></a>"#);
200+
println!(r#"<h2 id="{html_id}"><code>{name}</code></h2>"#);
150201
println!();
151202

152203
print!(
@@ -162,22 +213,6 @@ fn print_lint_warnings(lint: &LintWarnings, truncate_after: usize) {
162213
print_changed_diff(&lint.changed, truncate_after / 3);
163214
}
164215

165-
/// Prints a summary table of all lints with counts of added, removed, and changed warnings.
166-
fn print_summary_table(lints: &[LintWarnings]) {
167-
println!("| Lint | Added | Removed | Changed |");
168-
println!("| ------------------------------------------ | ------: | ------: | ------: |");
169-
170-
for lint in lints {
171-
println!(
172-
"| {:<62} | {:>7} | {:>7} | {:>7} |",
173-
format!("[`{}`](#user-content-{})", lint.name, to_html_id(&lint.name)),
174-
lint.added.len(),
175-
lint.removed.len(),
176-
lint.changed.len()
177-
);
178-
}
179-
}
180-
181216
/// Prints a section of warnings with a header and formatted code blocks.
182217
fn print_warnings(title: &str, warnings: &[LintJson], truncate_after: usize) {
183218
if warnings.is_empty() {
@@ -248,17 +283,16 @@ fn truncate<T>(list: &[T], truncate_after: usize) -> &[T] {
248283
}
249284
}
250285

251-
/// Prints a level 3 heading with an appropriate HTML ID for linking.
252286
fn print_h3(lint: &str, title: &str) {
253287
let html_id = to_html_id(lint);
254-
// We have to use HTML here to be able to manually add an id.
255-
println!(r#"### {title} <a id="user-content-{html_id}-{title}"></a>"#);
288+
// We have to use HTML here to be able to manually add an id, GitHub doesn't add them automatically
289+
println!(r#"<h3 id="{html_id}-{title}">{title}</h3>"#);
256290
}
257291

258-
/// GitHub's markdown parsers doesn't like IDs with `::` and `_`. This simplifies
259-
/// the lint name for the HTML ID.
292+
/// Creates a custom ID allowed by GitHub, they must start with `user-content-` and cannot contain
293+
/// `::`/`_`
260294
fn to_html_id(lint_name: &str) -> String {
261-
lint_name.replace("clippy::", "").replace('_', "-")
295+
lint_name.replace("clippy::", "user-content-").replace('_', "-")
262296
}
263297

264298
/// This generates the `x added` string for the start of the job summery.
@@ -270,9 +304,6 @@ fn count_string(lint: &str, label: &str, count: usize) -> String {
270304
format!("0 {label}")
271305
} else {
272306
let html_id = to_html_id(lint);
273-
// GitHub's job summaries don't add HTML ids to headings. That's why we
274-
// manually have to add them. GitHub prefixes these manual ids with
275-
// `user-content-` and that's how we end up with these awesome links :D
276-
format!("[{count} {label}](#user-content-{html_id}-{label})")
307+
format!("[{count} {label}](#{html_id}-{label})")
277308
}
278309
}

lintcheck/src/main.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,12 @@ fn main() {
303303
let config = LintcheckConfig::new();
304304

305305
match config.subcommand {
306-
Some(Commands::Diff { old, new, truncate }) => json::diff(&old, &new, truncate),
306+
Some(Commands::Diff {
307+
old,
308+
new,
309+
truncate,
310+
write_summary,
311+
}) => json::diff(&old, &new, truncate, write_summary),
307312
Some(Commands::Popular { output, number }) => popular_crates::fetch(output, number).unwrap(),
308313
None => lintcheck(config),
309314
}

0 commit comments

Comments
 (0)