Skip to main content

xtask/rmcp_release_monitor/
issue_body.rs

1//! Markdown assembly for the monitor's GitHub issue body.
2//!
3//! Takes the already-built reports and turns them into the single string the
4//! workflow writes to disk. Nothing here decides *whether* there is drift; it
5//! only renders what the report builders found, then clamps the result to
6//! GitHub's issue-body size limit.
7
8use 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}