xtask/rmcp_release_monitor/
conformance.rs1use anyhow::{Context, Result};
8use serde::Deserialize;
9use std::collections::BTreeSet;
10use std::path::PathBuf;
11
12use super::diagnostics::json_parse_context;
13use super::impact::{RepoImpact, append_impact_section, collect_identifiers, scan_repo_impacts};
14use super::short_sha;
15
16#[derive(Debug)]
17pub(super) struct ConformanceMonitorInput {
18 pub(super) baseline_sha: String,
19 pub(super) head_json: String,
20 pub(super) compare_json: Option<String>,
21 pub(super) url: String,
22 pub(super) repo_root: PathBuf,
23}
24
25#[derive(Debug)]
26pub(super) struct ConformanceReport {
27 pub(super) drift: bool,
28 pub(super) baseline_sha: String,
29 pub(super) head_sha: String,
30 url: String,
31 head_date: String,
32 head_message: String,
33 head_html_url: String,
34 commits: Vec<ConformanceCommit>,
35 files: Vec<ConformanceFile>,
36 impacts: Vec<RepoImpact>,
37}
38
39#[derive(Debug, Deserialize)]
40struct ConformanceHead {
41 sha: String,
42 html_url: String,
43 commit: ConformanceCommitDetails,
44}
45
46#[derive(Debug, Deserialize)]
47struct ConformanceCompare {
48 #[serde(default)]
49 commits: Vec<ConformanceCommit>,
50 #[serde(default)]
51 files: Vec<ConformanceFile>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
55struct ConformanceCommit {
56 sha: String,
57 html_url: String,
58 commit: ConformanceCommitDetails,
59}
60
61#[derive(Debug, Clone, Deserialize)]
62struct ConformanceCommitDetails {
63 message: String,
64 author: ConformanceCommitAuthor,
65}
66
67#[derive(Debug, Clone, Deserialize)]
68struct ConformanceCommitAuthor {
69 date: String,
70}
71
72#[derive(Debug, Deserialize)]
73struct ConformanceFile {
74 filename: String,
75 status: String,
76 additions: u64,
77 deletions: u64,
78 changes: u64,
79 #[serde(default)]
80 blob_url: Option<String>,
81 #[serde(default)]
82 patch: Option<String>,
83}
84
85pub(super) fn build_conformance_report(
86 input: &ConformanceMonitorInput,
87) -> Result<ConformanceReport> {
88 let head: ConformanceHead = serde_json::from_str(&input.head_json).with_context(|| {
89 json_parse_context(
90 "MCP conformance head JSON (--conformance-head-json)",
91 &input.head_json,
92 )
93 })?;
94 let baseline_sha = input.baseline_sha.trim().to_owned();
95 let drift = baseline_sha != head.sha;
96 let compare = input
97 .compare_json
98 .as_deref()
99 .map(|json| {
100 serde_json::from_str::<ConformanceCompare>(json).with_context(|| {
101 json_parse_context(
102 "MCP conformance compare JSON (--conformance-compare-json)",
103 json,
104 )
105 })
106 })
107 .transpose()?;
108 let commits = compare
109 .as_ref()
110 .map(|compare| compare.commits.clone())
111 .unwrap_or_default();
112 let files = compare.map(|compare| compare.files).unwrap_or_default();
113 let changed_terms = if drift {
114 changed_terms_from_conformance_files(&files)
115 } else {
116 BTreeSet::new()
117 };
118 Ok(ConformanceReport {
119 drift,
120 baseline_sha,
121 head_sha: head.sha,
122 url: input.url.clone(),
123 head_date: head.commit.author.date,
124 head_message: head.commit.message,
125 head_html_url: head.html_url,
126 commits,
127 files,
128 impacts: if drift {
129 scan_repo_impacts(&input.repo_root, &changed_terms)?
130 } else {
131 Vec::new()
132 },
133 })
134}
135
136pub(super) fn append_conformance_section(body: &mut String, report: &ConformanceReport) {
137 body.push_str("## MCP Conformance Watch\n\n");
138 body.push_str(&format!(
139 "- Upstream repo: [{}]({})\n",
140 report.url, report.url
141 ));
142 body.push_str(&format!("- Baseline SHA: `{}`\n", report.baseline_sha));
143 body.push_str(&format!(
144 "- Head SHA: [`{}`]({})\n",
145 short_sha(&report.head_sha),
146 report.head_html_url
147 ));
148 body.push_str(&format!("- Head date: `{}`\n", report.head_date));
149 body.push_str(&format!("- Drift: `{}`\n\n", report.drift));
150 let head_summary = report.head_message.lines().next().unwrap_or("").trim();
151 if !head_summary.is_empty() {
152 body.push_str(&format!("Latest commit: {head_summary}\n\n"));
153 }
154 if !report.commits.is_empty() {
155 body.push_str("### New conformance commits\n\n");
156 for commit in report.commits.iter().take(10) {
157 let summary = commit.commit.message.lines().next().unwrap_or("").trim();
158 body.push_str(&format!(
159 "- [`{}`]({}) `{}` {}\n",
160 short_sha(&commit.sha),
161 commit.html_url,
162 commit.commit.author.date,
163 summary
164 ));
165 }
166 body.push('\n');
167 }
168 if !report.files.is_empty() {
169 body.push_str("### Changed conformance files\n\n");
170 body.push_str("| File | Status | +/- | Changes |\n");
171 body.push_str("|---|---:|---:|---:|\n");
172 for file in report.files.iter().take(20) {
173 let file_link = file
174 .blob_url
175 .as_ref()
176 .map(|url| format!("[`{}`]({url})", file.filename))
177 .unwrap_or_else(|| format!("`{}`", file.filename));
178 body.push_str(&format!(
179 "| {file_link} | `{}` | +{} / -{} | {} |\n",
180 file.status, file.additions, file.deletions, file.changes
181 ));
182 }
183 if report.files.len() > 20 {
184 body.push_str(&format!(
185 "| _{} more files_ | | | |\n",
186 report.files.len() - 20
187 ));
188 }
189 body.push('\n');
190 }
191 append_impact_section(
192 body,
193 "Potential conformance impact in this repo",
194 &report.impacts,
195 );
196}
197
198fn changed_terms_from_conformance_files(files: &[ConformanceFile]) -> BTreeSet<String> {
199 let mut terms = BTreeSet::new();
200 for file in files {
201 collect_identifiers(&file.filename, &mut terms);
202 if let Some(patch) = &file.patch {
203 for line in patch.lines() {
204 if line.starts_with('+') || line.starts_with('-') {
205 collect_identifiers(line, &mut terms);
206 }
207 }
208 }
209 }
210 terms
211}