1use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
19use crate::capability::handler::{CapabilityError, Context};
20use crate::capability::model::{
21 BenchmarkPolicy, Exposure, McpExposure, McpResource, Stability, WaiverReason,
22};
23use crate::capability::module::ModuleDescriptor;
24use crate::capability::registry::CapabilityRegistry;
25use crate::release::compat::VersionPlan;
26use crate::release::{self, model::ProducedBy, model::VersionReport, Changelog};
27use crate::{capability, module};
28
29use super::{get, Empty};
30
31pub const CHANGELOG_URI: &str = "majordomus://changelog";
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
36#[serde(deny_unknown_fields)]
37#[schemars(rename = "ReleaseChangelogInput")]
38pub struct ChangelogInput {
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub version: Option<String>,
42}
43
44impl BenchmarkCases for ChangelogInput {
45 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
52 let version = ctx
53 .index
54 .objects
55 .iter()
56 .find(|o| o.kind == crate::release::changelog::RELEASE_KIND)
57 .and_then(|o| o.metadata.get("version"))
58 .and_then(|v| v.as_str())
59 .map_or_else(|| "unreleased".to_string(), str::to_string);
60 vec![
61 NamedCase::new("all", ChangelogInput::default()),
62 NamedCase::new(
63 "one-version",
64 ChangelogInput {
65 version: Some(version),
66 },
67 ),
68 ]
69 }
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84#[schemars(rename = "ReleaseAnalysisInput")]
85pub struct AnalysisInput {
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub since: Option<String>,
90}
91
92impl BenchmarkCases for AnalysisInput {
93 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
100 Vec::new()
101 }
102}
103
104#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
113#[serde(deny_unknown_fields)]
114#[schemars(rename = "ReleaseNextInput")]
115pub struct NextInput {
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub base: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub head: Option<String>,
122}
123
124impl NextInput {
125 pub fn base(&self) -> &str {
133 self.base.as_deref().unwrap_or("origin/master")
134 }
135
136 pub fn head(&self) -> &str {
143 self.head.as_deref().unwrap_or("HEAD")
144 }
145}
146
147impl BenchmarkCases for NextInput {
148 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
151 Vec::new()
152 }
153}
154
155pub fn module() -> ModuleDescriptor {
157 module! {
158 id: "release",
159 title: "Release",
160 description: "What this project has shipped and what it would ship next, derived rather than maintained: the changelog composes the layer's release records, the decisions dated inside each release's window and the conventional commits in its range; the version report reads the one place the version is authored and the projection the shell tool prints, and says what the public contract requires it to become — or that it cannot be measured, and so decides nothing — with what the commits since the last release imply beside it as evidence.",
161 stability: Stability::Implemented,
162 capabilities: [
163 capability! {
164 id: CHANGELOG_ID,
165 title: "The changelog",
166 description: "Every release the layer records, newest first, with the work that has not been released leading. A section's decisions are the ADRs dated inside that release's window, its changes the conventional commits in its range, its artifacts the record's own evidence. Nothing in it is authored, and a section that could not be read says so rather than appearing empty.",
167 input: ChangelogInput,
168 output: Changelog,
169 stability: Stability::Implemented,
170 exposure: Exposure {
171 mcp: Some(McpExposure {
172 tool: Some("majordomus_changelog".into()),
173 resource: Some(McpResource { uri: CHANGELOG_URI.into(), name: "changelog".into() }),
174 }),
175 http: get("/api/v1/changelog"),
176 cli: None,
182 },
183 tags: ["release", "changelog"],
184 handler: changelog,
185 },
186 capability! {
187 id: "release.version",
188 title: "The version, and the one the contract requires next",
189 description: "The version the crate manifest declares — the one place it is authored — the version the shell tool prints from its projection `share/version.txt`, and whether that projection is current — the question `generate --check` refuses and `scripts/release-version --check` gates on. Then the next version, which is the public contract's answer: the version `release analyze` requires against the last release — the declared version when it already satisfies the contract, otherwise the smallest one it allows, and the smallest release above the last when the contract requires none over it — with `decided_by` naming who answered. It is the one release selection `release bump` also raises to when no target is named, so the report and the writer cannot disagree. When the contract cannot be measured — no published baseline, a version that is not three numbers, or any other error that makes the plan unsound, the plan `release bump` refuses to write from — the baseline is refused, not guessed: `next` is absent, `decided_by` is `undecided` and `contract_unreadable` carries every error, one per line. The bump the conventional commits since the last release imply, the version it would produce and the commits themselves are carried beside it as evidence, and never answer `next` in the contract's place.",
190 input: Empty,
191 output: VersionReport,
192 stability: Stability::Implemented,
193 exposure: Exposure {
194 mcp: Some(McpExposure {
195 tool: Some("majordomus_release_version".into()),
196 resource: None,
197 }),
198 http: get("/api/v1/release/version"),
199 cli: None,
200 },
201 tags: ["release", "version"],
202 cache: crate::capability::CachePolicy::Checkout { max_entries: 2 },
203 handler: version,
204 },
205 capability! {
206 id: ANALYSIS_ID,
207 title: "What the public contract did, and the smallest version it allows",
208 description: "The public capability surface of this tree against the one the last release published, every movement of it named with the reason it counts for what it does, and the smallest version this tree may therefore declare. The compatibility level is measured from the contract rather than read off the commit subjects: a capability, route, MCP tool, command, or a required field of either schema that a caller could hold and cannot any more is breaking whatever the commit that removed it called itself. What the commits said about themselves is carried beside the verdict as evidence, and the answer says when the two disagree.",
209 input: AnalysisInput,
210 output: VersionPlan,
211 stability: Stability::Implemented,
212 exposure: Exposure {
213 mcp: Some(McpExposure {
214 tool: Some("majordomus_release_analysis".into()),
215 resource: None,
216 }),
217 http: get("/api/v1/release/analysis"),
218 cli: None,
222 },
223 tags: ["release", "version", "compatibility"],
224 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::PublishedHistory },
230 handler: analysis,
231 },
232 capability! {
233 id: NEXT_ID,
234 title: "The version a landing allocates, and why",
235 description: "What the integration executor raises the trunk's version to when it lands a head (ADR 0134): the version the trunk declares, how far the head moves the public contract measured against the trunk's committed registry, what that movement requires under the version's mode, the repository's floor from `release.floor` in the policy, the exemption class that lowered the floor if every changed path falls in one, the larger of the requirement and the floor, and the trunk's version raised by it — with one sentence per step. Everything is read from git at the two refs, so the answer does not depend on which executable runs or on the branch's own version, which is why a retried landing cannot raise twice. It decides and writes nothing; an input that cannot be read is refused rather than guessed.",
236 input: NextInput,
237 output: release::landing::LandingDecision,
238 stability: Stability::Implemented,
239 exposure: Exposure {
240 mcp: Some(McpExposure {
241 tool: Some("majordomus_release_next".into()),
242 resource: None,
243 }),
244 http: get("/api/v1/release/next"),
245 cli: None,
248 },
249 tags: ["release", "version", "integration"],
250 benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::PublishedHistory },
251 handler: next,
252 },
253 ],
254 }
255}
256
257pub const ANALYSIS_ID: &str = "release.analysis";
259
260pub const NEXT_ID: &str = "release.next";
262
263pub const CHANGELOG_ID: &str = "release.changelog";
267
268pub fn produced_by(registry: &CapabilityRegistry) -> Option<ProducedBy> {
295 let c = registry.get(CHANGELOG_ID)?;
296 let cli = crate::cli::local::LOCAL
297 .iter()
298 .find(|l| l.reason.renders() == Some(CHANGELOG_ID))
299 .map(|l| format!("majordomus {}", l.command));
300 Some(ProducedBy {
301 capability: c.id.to_string(),
302 cli,
303 http: c.exposure.http.as_ref().map(|h| h.path.clone()),
304 mcp_tool: c.exposure.mcp.as_ref().and_then(|m| m.tool.clone()),
305 mcp_resource: c
306 .exposure
307 .mcp
308 .as_ref()
309 .and_then(|m| m.resource.as_ref().map(|r| r.uri.clone())),
310 })
311}
312
313fn changelog(ctx: &Context, input: ChangelogInput) -> Result<Changelog, CapabilityError> {
314 let root = std::path::Path::new(&ctx.index.repository.root);
315 let mut log = release::compose(root, &ctx.index.objects);
316 log.produced_by = produced_by(&ctx.registry);
317 if let Some(wanted) = input.version.as_deref() {
318 log.sections.retain(|s| {
319 s.version == wanted
320 || s.tag.as_deref() == Some(wanted)
321 || (wanted == "unreleased" && s.unreleased)
322 });
323 if log.sections.is_empty() {
324 return Err(CapabilityError::NotFound(format!(
325 "no release section carries the version '{wanted}'"
326 )));
327 }
328 }
329 Ok(log)
330}
331
332fn version(ctx: &Context, _: Empty) -> Result<VersionReport, CapabilityError> {
340 let root = std::path::Path::new(&ctx.index.repository.root);
341 Ok(release::version::select(root, &ctx.registry, &ctx.index.objects).report)
342}
343
344fn analysis(ctx: &Context, input: AnalysisInput) -> Result<VersionPlan, CapabilityError> {
350 let root = std::path::Path::new(&ctx.index.repository.root);
351 release::compat::analyze(
352 root,
353 &ctx.registry,
354 &ctx.index.objects,
355 input.since.as_deref(),
356 )
357 .map_err(|e| CapabilityError::NotFound(e.to_string()))
358}
359
360fn next(
364 ctx: &Context,
365 input: NextInput,
366) -> Result<release::landing::LandingDecision, CapabilityError> {
367 let root = std::path::Path::new(&ctx.index.repository.root);
368 let repo = crate::repository::Repository::open(root).map_err(|e| {
369 CapabilityError::NotFound(format!("the repository could not be opened: {e}"))
370 })?;
371 let policy = crate::policy::LoadedPolicy::load(&repo)
372 .map_err(|e| CapabilityError::NotFound(format!("the policy could not be read: {e}")))?
373 .policy
374 .release;
375 release::landing::next_for(root, input.base(), input.head(), &policy)
376 .map_err(|e| CapabilityError::NotFound(e.to_string()))
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
385 fn a_landing_in_a_repository_that_cannot_be_opened_is_refused() {
386 let repo = crate::synthetic::SyntheticRepository::small().expect("a synthetic repository");
387 let ctx = repo.context().expect("a context");
388 std::fs::remove_file(repo.root().join(crate::repository::MANIFEST)).expect("the manifest");
389 match next(&ctx, NextInput::default()) {
390 Err(CapabilityError::NotFound(m)) => {
391 assert!(m.contains("could not be opened"), "{m}")
392 }
393 other => panic!("not refused as not found: {other:?}"),
394 }
395 }
396
397 #[test]
399 fn a_landing_under_an_unreadable_policy_is_refused() {
400 let repo = crate::synthetic::SyntheticRepository::small().expect("a synthetic repository");
401 let ctx = repo.context().expect("a context");
402 let policy = repo.root().join(".ai/repo/policy.yaml");
403 let text = std::fs::read_to_string(&policy).expect("the policy");
404 std::fs::write(&policy, format!("{text}release:\n allocation: sideways\n"))
405 .expect("the policy");
406 match next(&ctx, NextInput::default()) {
407 Err(CapabilityError::NotFound(m)) => {
408 assert!(m.contains("the policy could not be read"), "{m}")
409 }
410 other => panic!("not refused as not found: {other:?}"),
411 }
412 }
413
414 #[test]
420 fn the_declaration_yields_the_projections_it_claims() {
421 let m = module();
422 assert_eq!(m.id.as_str(), "release");
423 assert_eq!(
424 ANALYSIS_ID, "release.analysis",
425 "the id beside the declaration is the declared one"
426 );
427 let expected: &[(&str, &str, &str)] = &[
428 (
429 "release.changelog",
430 "majordomus_changelog",
431 "/api/v1/changelog",
432 ),
433 (
434 "release.version",
435 "majordomus_release_version",
436 "/api/v1/release/version",
437 ),
438 (
439 "release.analysis",
440 "majordomus_release_analysis",
441 "/api/v1/release/analysis",
442 ),
443 (
444 "release.next",
445 "majordomus_release_next",
446 "/api/v1/release/next",
447 ),
448 ];
449 let ids: Vec<&str> = m
450 .capabilities
451 .iter()
452 .map(|e| e.capability.id.as_str())
453 .collect();
454 let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
455 assert_eq!(
456 ids, want,
457 "the module declares a different set of capabilities"
458 );
459 for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
460 let exposure = &executable.capability.exposure;
461 assert_eq!(
462 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
463 Some(*tool),
464 "{id} lost or renamed its MCP tool"
465 );
466 assert_eq!(
467 exposure.http.as_ref().map(|h| h.path.as_str()),
468 Some(*path),
469 "{id} lost or renamed its HTTP route"
470 );
471 }
472 let changelog = &m.capabilities[0].capability.exposure;
474 assert_eq!(
475 changelog
476 .mcp
477 .as_ref()
478 .and_then(|m| m.resource.as_ref())
479 .map(|r| r.uri.as_str()),
480 Some(CHANGELOG_URI),
481 "the changelog stopped being an MCP resource"
482 );
483 }
484}