Skip to content

Commit 686cf7e

Browse files
committed
Create summary comment for lintcheck runs
1 parent 1a864f3 commit 686cf7e

File tree

5 files changed

+197
-49
lines changed

5 files changed

+197
-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: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
# If we already posted a comment and there are now no changes update it to say that
99+
gh pr comment "$PR" --body "No changes for ${{ github.event.workflow_run.head_sha }}" --edit-last || true
100+
fi
101+
env:
102+
GH_TOKEN: ${{ github.token }}
103+
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
@@ -297,7 +297,12 @@ fn main() {
297297
let config = LintcheckConfig::new();
298298

299299
match config.subcommand {
300-
Some(Commands::Diff { old, new, truncate }) => json::diff(&old, &new, truncate),
300+
Some(Commands::Diff {
301+
old,
302+
new,
303+
truncate,
304+
write_summary,
305+
}) => json::diff(&old, &new, truncate, write_summary),
301306
Some(Commands::Popular { output, number }) => popular_crates::fetch(output, number).unwrap(),
302307
None => lintcheck(config),
303308
}

0 commit comments

Comments
 (0)