Rollup merge of #139481 - Kobzol:post-merge-links, r=marcoieni

Add job summary links to post-merge report

This should make it much easier to investigate the individual job test/duration changes.

The GitHub API handling is a bit crude, but I didn't want to include octocrab, because it more than doubles the current number of dependencies of `citool`...

Can be tested with:
```bash
$ cargo run --manifest-path src/ci/citool/Cargo.toml post-merge-report bad13a970a 1e008dd5d8
```

r? ```@marcoieni```
This commit is contained in:
Matthias Krüger 2025-04-09 14:52:39 +02:00 committed by GitHub
commit 704d301b43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 153 additions and 11 deletions

View file

@ -7,6 +7,7 @@ use build_helper::metrics::{
format_build_steps,
};
use crate::github::JobInfoResolver;
use crate::metrics;
use crate::metrics::{JobMetrics, JobName, get_test_suites};
use crate::utils::{output_details, pluralize};
@ -185,13 +186,19 @@ fn render_table(suites: BTreeMap<String, TestSuiteRecord>) -> String {
}
/// Outputs a report of test differences between the `parent` and `current` commits.
pub fn output_test_diffs(job_metrics: &HashMap<JobName, JobMetrics>) {
pub fn output_test_diffs(
job_metrics: &HashMap<JobName, JobMetrics>,
job_info_resolver: &mut JobInfoResolver,
) {
let aggregated_test_diffs = aggregate_test_diffs(&job_metrics);
report_test_diffs(aggregated_test_diffs);
report_test_diffs(aggregated_test_diffs, job_metrics, job_info_resolver);
}
/// Prints the ten largest differences in bootstrap durations.
pub fn output_largest_duration_changes(job_metrics: &HashMap<JobName, JobMetrics>) {
pub fn output_largest_duration_changes(
job_metrics: &HashMap<JobName, JobMetrics>,
job_info_resolver: &mut JobInfoResolver,
) {
struct Entry<'a> {
job: &'a JobName,
before: Duration,
@ -225,14 +232,14 @@ pub fn output_largest_duration_changes(job_metrics: &HashMap<JobName, JobMetrics
});
}
}
changes.sort_by(|e1, e2| e1.change.partial_cmp(&e2.change).unwrap().reverse());
changes.sort_by(|e1, e2| e1.change.abs().partial_cmp(&e2.change.abs()).unwrap().reverse());
println!("# Job duration changes");
for (index, entry) in changes.into_iter().take(10).enumerate() {
println!(
"{}. `{}`: {:.1}s -> {:.1}s ({:.1}%)",
"{}. {}: {:.1}s -> {:.1}s ({:.1}%)",
index + 1,
entry.job,
format_job_link(job_info_resolver, job_metrics, entry.job),
entry.before.as_secs_f64(),
entry.after.as_secs_f64(),
entry.change
@ -400,7 +407,11 @@ fn generate_test_name(name: &str) -> String {
}
/// Prints test changes in Markdown format to stdout.
fn report_test_diffs(diff: AggregatedTestDiffs) {
fn report_test_diffs(
diff: AggregatedTestDiffs,
job_metrics: &HashMap<JobName, JobMetrics>,
job_info_resolver: &mut JobInfoResolver,
) {
println!("# Test differences");
if diff.diffs.is_empty() {
println!("No test diffs found");
@ -521,9 +532,26 @@ fn report_test_diffs(diff: AggregatedTestDiffs) {
println!(
"- {}: {}",
format_job_group(group as u64),
jobs.iter().map(|j| format!("`{j}`")).collect::<Vec<_>>().join(", ")
jobs.iter()
.map(|j| format_job_link(job_info_resolver, job_metrics, j))
.collect::<Vec<_>>()
.join(", ")
);
}
},
);
}
/// Tries to get a GitHub Actions job summary URL from the resolver.
/// If it is not available, just wraps the job name in backticks.
fn format_job_link(
job_info_resolver: &mut JobInfoResolver,
job_metrics: &HashMap<JobName, JobMetrics>,
job_name: &str,
) -> String {
job_metrics
.get(job_name)
.and_then(|metrics| job_info_resolver.get_job_summary_link(job_name, &metrics.current))
.map(|summary_url| format!("[{job_name}]({summary_url})"))
.unwrap_or_else(|| format!("`{job_name}`"))
}

109
src/ci/citool/src/github.rs Normal file
View file

@ -0,0 +1,109 @@
use std::collections::HashMap;
use anyhow::Context;
use build_helper::metrics::{CiMetadata, JsonRoot};
pub struct GitHubClient;
impl GitHubClient {
fn get_workflow_run_jobs(
&self,
repo: &str,
workflow_run_id: u64,
) -> anyhow::Result<Vec<GitHubJob>> {
let req = ureq::get(format!(
"https://api.github.com/repos/{repo}/actions/runs/{workflow_run_id}/jobs?per_page=100"
))
.header("User-Agent", "rust-lang/rust/citool")
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.call()
.context("cannot get workflow job list")?;
let status = req.status();
let mut body = req.into_body();
if status.is_success() {
// This API response is actually paged, but we assume for now that there are at
// most 100 jobs per workflow.
let response = body
.read_json::<WorkflowRunJobsResponse>()
.context("cannot deserialize workflow run jobs response")?;
// The CI job names have a prefix, e.g. `auto - foo`. We remove the prefix here to
// normalize the job name.
Ok(response
.jobs
.into_iter()
.map(|mut job| {
job.name = job
.name
.split_once(" - ")
.map(|res| res.1.to_string())
.unwrap_or_else(|| job.name);
job
})
.collect())
} else {
Err(anyhow::anyhow!(
"Cannot get jobs of workflow run {workflow_run_id}: {status}\n{}",
body.read_to_string()?
))
}
}
}
#[derive(serde::Deserialize)]
struct WorkflowRunJobsResponse {
jobs: Vec<GitHubJob>,
}
#[derive(serde::Deserialize)]
struct GitHubJob {
name: String,
id: u64,
}
/// Can be used to resolve information about GitHub Actions jobs.
/// Caches results internally to avoid too unnecessary GitHub API calls.
pub struct JobInfoResolver {
client: GitHubClient,
// Workflow run ID -> jobs
workflow_job_cache: HashMap<u64, Vec<GitHubJob>>,
}
impl JobInfoResolver {
pub fn new() -> Self {
Self { client: GitHubClient, workflow_job_cache: Default::default() }
}
/// Get a link to a job summary for the given job name and bootstrap execution.
pub fn get_job_summary_link(&mut self, job_name: &str, metrics: &JsonRoot) -> Option<String> {
metrics.ci_metadata.as_ref().and_then(|metadata| {
self.get_job_id(metadata, job_name).map(|job_id| {
format!(
"https://github.com/{}/actions/runs/{}#summary-{job_id}",
metadata.repository, metadata.workflow_run_id
)
})
})
}
fn get_job_id(&mut self, ci_metadata: &CiMetadata, job_name: &str) -> Option<u64> {
if let Some(job) = self
.workflow_job_cache
.get(&ci_metadata.workflow_run_id)
.and_then(|jobs| jobs.iter().find(|j| j.name == job_name))
{
return Some(job.id);
}
let jobs = self
.client
.get_workflow_run_jobs(&ci_metadata.repository, ci_metadata.workflow_run_id)
.inspect_err(|e| eprintln!("Cannot download workflow jobs: {e:?}"))
.ok()?;
let job_id = jobs.iter().find(|j| j.name == job_name).map(|j| j.id);
// Save the cache even if the job name was not found, it could be useful for further lookups
self.workflow_job_cache.insert(ci_metadata.workflow_run_id, jobs);
job_id
}
}

View file

@ -1,6 +1,7 @@
mod analysis;
mod cpu_usage;
mod datadog;
mod github;
mod jobs;
mod metrics;
mod utils;
@ -18,6 +19,7 @@ use serde_yaml::Value;
use crate::analysis::{output_largest_duration_changes, output_test_diffs};
use crate::cpu_usage::load_cpu_usage;
use crate::datadog::upload_datadog_metric;
use crate::github::JobInfoResolver;
use crate::jobs::RunType;
use crate::metrics::{JobMetrics, download_auto_job_metrics, download_job_metrics, load_metrics};
use crate::utils::load_env_var;
@ -145,6 +147,7 @@ fn postprocess_metrics(
) -> anyhow::Result<()> {
let metrics = load_metrics(&metrics_path)?;
let mut job_info_resolver = JobInfoResolver::new();
if let (Some(parent), Some(job_name)) = (parent, job_name) {
// This command is executed also on PR builds, which might not have parent metrics
// available, because some PR jobs don't run on auto builds, and PR jobs do not upload metrics
@ -160,7 +163,7 @@ fn postprocess_metrics(
job_name,
JobMetrics { parent: Some(parent_metrics), current: metrics },
)]);
output_test_diffs(&job_metrics);
output_test_diffs(&job_metrics, &mut job_info_resolver);
return Ok(());
}
Err(error) => {
@ -180,8 +183,10 @@ fn post_merge_report(db: JobDatabase, current: String, parent: String) -> anyhow
let metrics = download_auto_job_metrics(&db, &parent, &current)?;
println!("\nComparing {parent} (parent) -> {current} (this PR)\n");
output_test_diffs(&metrics);
output_largest_duration_changes(&metrics);
let mut job_info_resolver = JobInfoResolver::new();
output_test_diffs(&metrics, &mut job_info_resolver);
output_largest_duration_changes(&metrics, &mut job_info_resolver);
Ok(())
}