xtask/rmcp_release_monitor/
issue_body.rs1use anyhow::Result;
9use semver::Version;
10
11use super::conformance::{ConformanceReport, append_conformance_section};
12use super::schema::{SchemaReport, append_schema_section};
13use super::{CrateVersion, CratesIoResponse, GithubRelease, MARKER};
14
15pub(super) fn render_issue_body(
16 metadata: &CratesIoResponse,
17 releases: &[GithubRelease],
18 current: &Version,
19 latest: &Version,
20 schema_report: Option<&SchemaReport>,
21 conformance_report: Option<&ConformanceReport>,
22 max_body_bytes: usize,
23) -> Result<String> {
24 let released_versions = released_versions_between(metadata, current, latest);
25 let repository = metadata
26 .crate_info
27 .repository
28 .as_deref()
29 .or(metadata.crate_info.homepage.as_deref());
30 let compare_url = repository.and_then(|repo| github_compare_url(repo, current, latest));
31
32 let mut body = String::new();
33 body.push_str(MARKER);
34 body.push('\n');
35 body.push_str(&format!("<!-- rmcp-current-version: {current} -->\n"));
36 body.push_str(&format!("<!-- rmcp-latest-version: {latest} -->\n\n"));
37 if let Some(report) = schema_report {
38 body.push_str(&format!(
39 "<!-- mcp-schema-baseline-sha256: {} -->\n",
40 report.baseline_hash
41 ));
42 body.push_str(&format!(
43 "<!-- mcp-schema-upstream-sha256: {} -->\n",
44 report.upstream_hash
45 ));
46 }
47 if let Some(report) = conformance_report {
48 body.push_str(&format!(
49 "<!-- mcp-conformance-baseline-sha: {} -->\n",
50 report.baseline_sha
51 ));
52 body.push_str(&format!(
53 "<!-- mcp-conformance-head-sha: {} -->\n",
54 report.head_sha
55 ));
56 }
57 body.push('\n');
58 if latest > current {
59 body.push_str(&format!(
60 "`rmcp` has a newer published crate release. Soma currently pins `{current}` and crates.io now publishes `{latest}`.\n\n"
61 ));
62 body.push_str("## Release Window\n\n");
63 body.push_str("| Version | Published | Yanked | Links |\n");
64 body.push_str("|---|---:|:---:|---|\n");
65 for version in &released_versions {
66 let release = find_release(releases, &version.num);
67 let release_link = release
68 .and_then(|release| release.html_url.as_deref())
69 .map(|url| format!(" [release]({url})"))
70 .unwrap_or_default();
71 body.push_str(&format!(
72 "| `{}` | `{}` | {} | [crates.io](https://crates.io/crates/rmcp/{}){} |\n",
73 version.num,
74 version.created_at,
75 if version.yanked { "yes" } else { "no" },
76 version.num,
77 release_link
78 ));
79 }
80 body.push('\n');
81 }
82 if let Some(report) = schema_report {
83 append_schema_section(&mut body, report);
84 }
85 if let Some(report) = conformance_report {
86 append_conformance_section(&mut body, report);
87 }
88 body.push_str("## Review Links\n\n");
89 body.push_str("- [rmcp on crates.io](https://crates.io/crates/rmcp)\n");
90 if let Some(docs) = &metadata.crate_info.documentation {
91 body.push_str(&format!("- [docs.rs]({docs})\n"));
92 }
93 if let Some(repo) = repository {
94 body.push_str(&format!("- [upstream repository]({repo})\n"));
95 }
96 if let Some(url) = compare_url {
97 body.push_str(&format!("- [upstream compare]({url})\n"));
98 }
99 body.push('\n');
100 if latest > current {
101 body.push_str("## Release Notes\n\n");
102 for version in &released_versions {
103 let release = find_release(releases, &version.num);
104 body.push_str(&format!("### rmcp v{}\n\n", version.num));
105 if let Some(release) = release {
106 if let Some(published_at) = &release.published_at {
107 body.push_str(&format!("Published: `{published_at}`\n\n"));
108 }
109 if let Some(name) = &release.name {
110 body.push_str(&format!("Release: `{name}`\n\n"));
111 }
112 let notes = release.body.as_deref().unwrap_or("").trim();
113 if notes.is_empty() {
114 body.push_str("_No GitHub release notes were published for this tag._\n\n");
115 } else {
116 body.push_str(notes);
117 body.push_str("\n\n");
118 }
119 } else {
120 body.push_str("_No matching GitHub release was found for this crate version._\n\n");
121 }
122 }
123 }
124 body.push_str("## Suggested Follow-Up\n\n");
125 body.push_str(
126 "- Read the release, schema, and conformance sections above for source-breaking changes.\n",
127 );
128 body.push_str("- Update all `rmcp` pins together when rmcp drift is present.\n");
129 body.push_str("- Refresh the pinned MCP schema baseline after reviewing schema drift.\n");
130 body.push_str(
131 "- Refresh the pinned MCP conformance baseline after reviewing conformance drift.\n",
132 );
133 body.push_str(
134 "- Run `cargo update -p rmcp`, `cargo test`, and the MCP dispatch/schema/conformance checks.\n",
135 );
136 body.push_str("- Update Soma docs/examples if the rmcp API or feature flags changed.\n");
137 Ok(clamp_issue_body(body, max_body_bytes))
138}
139
140fn released_versions_between<'a>(
141 metadata: &'a CratesIoResponse,
142 current: &Version,
143 latest: &Version,
144) -> Vec<&'a CrateVersion> {
145 let mut versions = metadata
146 .versions
147 .iter()
148 .filter(|version| !version.yanked)
149 .filter(|version| {
150 Version::parse(&version.num)
151 .map(|parsed| parsed > *current && parsed <= *latest)
152 .unwrap_or(false)
153 })
154 .collect::<Vec<_>>();
155 versions.sort_by(|left, right| {
156 Version::parse(&left.num)
157 .unwrap_or_else(|_| Version::new(0, 0, 0))
158 .cmp(&Version::parse(&right.num).unwrap_or_else(|_| Version::new(0, 0, 0)))
159 });
160 versions
161}
162
163fn find_release<'a>(releases: &'a [GithubRelease], version: &str) -> Option<&'a GithubRelease> {
164 let tag = format!("rmcp-v{version}");
165 releases.iter().find(|release| release.tag_name == tag)
166}
167
168fn github_compare_url(repo: &str, current: &Version, latest: &Version) -> Option<String> {
169 let trimmed = repo.trim_end_matches('/').trim_end_matches(".git");
170 let path = trimmed.strip_prefix("https://github.com/")?;
171 Some(format!(
172 "https://github.com/{path}/compare/rmcp-v{current}...rmcp-v{latest}"
173 ))
174}
175
176pub(super) fn clamp_issue_body(mut body: String, max_body_bytes: usize) -> String {
177 let marker = "\n\n<!-- rmcp-release-monitor-truncated: true -->\n\n_Release notes were truncated to keep this issue body under GitHub's size limit. Use the release and compare links above for the full upstream changes._\n";
178 if body.len() <= max_body_bytes || max_body_bytes <= marker.len() {
179 return body;
180 }
181 let mut keep_bytes = max_body_bytes - marker.len();
182 while !body.is_char_boundary(keep_bytes) {
183 keep_bytes = keep_bytes.saturating_sub(1);
184 }
185 body.truncate(keep_bytes);
186 body.push_str(marker);
187 body
188}