1use serde_json::Value;
15
16use super::context::Ctx;
17use crate::detect::Forge;
18use crate::release_state::{self, Answer, Api, ForgeCli, Registry};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Milestone {
23 pub id: &'static str,
25 pub status: &'static str,
28 pub detail: String,
30}
31
32impl Milestone {
33 #[must_use]
35 pub fn line(&self) -> String {
36 format!("milestone {}: {} ({})", self.id, self.status, self.detail)
37 }
38}
39
40pub const FIRST_VERSION: &str = "first-version-published";
42pub const AUTOMATED_RELEASE: &str = "automated-release-proven";
44
45const fn word(answer: Answer) -> &'static str {
47 match answer {
48 Answer::Yes => "observed",
49 Answer::No => "not-observed",
50 Answer::Unknown => "unknown",
51 Answer::NotApplicable => "not-applicable",
52 }
53}
54
55fn operator_forms(
58 driver: &str,
59 package: &str,
60 forge: Option<Forge>,
61 repo: &str,
62) -> Vec<(&'static str, String)> {
63 match driver {
64 "rust" => vec![
65 (
66 "trusted-publisher",
67 format!(
68 "rk binding rust, the trusted publisher: the Trusted Publishing table on crates.io/crates/{package}/settings lists the owner, the repository, and release-plz.yml"
69 ),
70 ),
71 (
72 "bootstrap-token-revoked",
73 "rk binding rust, revoking the bootstrap token: the token is gone from crates.io/settings/tokens, and the host's cargo credentials name no crates-io token".to_owned(),
74 ),
75 (
76 "trusted-publishing-required",
77 format!(
78 "rk binding rust, the trusted publisher: on crates.io/crates/{package}/settings, require trusted publishing for all new versions reads as enabled after a reload"
79 ),
80 ),
81 ],
82 "python" => vec![
83 (
84 "trusted-publisher",
85 if forge == Some(Forge::Gitlab) {
86 format!(
87 "rk binding python, setup specifics: the publishing settings of {package} on PyPI list a GitLab publisher with the namespace and the project of {repo} and the top-level pipeline file .gitlab-ci.yml"
88 )
89 } else {
90 format!(
91 "rk binding python, setup specifics: the publishing settings of {package} on PyPI list the owner, the repository, the workflow release.yml, and the environment pypi"
92 )
93 },
94 ),
95 (
96 "bootstrap-token-revoked",
97 "rk binding python, setup specifics: the project-scoped API token is gone from the PyPI account's API tokens".to_owned(),
98 ),
99 ],
100 _ => Vec::new(),
101 }
102}
103
104#[must_use]
106pub fn observe(ctx: &Ctx) -> Vec<Milestone> {
107 let Some(driver) = ctx.driver().filter(|_| ctx.automatic_release()) else {
108 let reason = "the target selects no automated release".to_owned();
109 return vec![
110 Milestone {
111 id: FIRST_VERSION,
112 status: "not-applicable",
113 detail: reason.clone(),
114 },
115 Milestone {
116 id: AUTOMATED_RELEASE,
117 status: "not-applicable",
118 detail: reason,
119 },
120 ];
121 };
122 let registry = release_state::registry_for(driver, ctx.profile.release.registry);
123 let package = crate::release_engine::adapter::package_name(ctx.target.as_std_path(), driver);
124 let mut found = vec![first_version(registry, package.as_deref())];
125 found.push(automated_release(ctx, driver));
126 if let Some(package) = package.as_deref().filter(|_| registry.is_some()) {
130 for (id, check) in operator_forms(driver, package, ctx.forge, &ctx.repo) {
131 found.push(Milestone {
132 id,
133 status: "operator",
134 detail: check,
135 });
136 }
137 }
138 found
139}
140
141fn first_version(registry: Option<Registry>, package: Option<&str>) -> Milestone {
142 let (status, detail) = match (registry, package) {
143 (None, _) => (
144 "not-applicable",
145 "the release publishes to no registry; the release page is the distribution".to_owned(),
146 ),
147 (Some(_), None) => (
148 "unknown",
149 "the version file names no package the registry can be asked about".to_owned(),
150 ),
151 (Some(registry), Some(package)) => {
152 match release_state::registry_serves(registry, package, None) {
155 Ok(answer) => (
156 word(answer),
157 format!("{} answers for {package}", registry.url(package, None)),
158 ),
159 Err(reason) => ("unknown", reason),
160 }
161 }
162 };
163 Milestone {
164 id: FIRST_VERSION,
165 status,
166 detail,
167 }
168}
169
170fn automated_release(ctx: &Ctx, driver: &str) -> Milestone {
171 let milestone = |status, detail: String| Milestone {
172 id: AUTOMATED_RELEASE,
173 status,
174 detail,
175 };
176 let Some(forge) = ctx.forge else {
177 return milestone(
178 "not-applicable",
179 "the target names no forge this release reads".to_owned(),
180 );
181 };
182 if ctx.repo.is_empty() {
183 return milestone("unknown", "the target names no forge repository".to_owned());
184 }
185 let cli = ForgeCli::new(ctx.cli.clone(), ctx.target.as_std_path().to_path_buf());
186 let trunk = ctx.trunk();
187 let unknown =
188 |reason: String| milestone("unknown", format!("the forge did not answer: {reason}"));
189 let releases_path = match forge {
193 Forge::Github => format!("repos/{}/releases?per_page=1", ctx.repo),
194 Forge::Gitlab => format!(
195 "projects/{}/releases?per_page=1",
196 release_state::gitlab_project(&ctx.repo)
197 ),
198 };
199 let tag = match cli.api(&releases_path) {
200 Api::Ok(body) => body
201 .as_array()
202 .and_then(|list| list.first())
203 .and_then(|release| release["tag_name"].as_str())
204 .map(str::to_owned),
205 Api::Missing => None,
206 Api::Failed(reason) => return unknown(reason),
207 };
208 let Some(tag) = tag else {
209 return milestone("not-observed", "no release exists yet".to_owned());
210 };
211 let (commit_path, commit_pointer) = match forge {
212 Forge::Github => (format!("repos/{}/commits/{tag}", ctx.repo), "/sha"),
213 Forge::Gitlab => (
214 format!(
215 "projects/{}/repository/tags/{tag}",
216 release_state::gitlab_project(&ctx.repo)
217 ),
218 "/commit/id",
219 ),
220 };
221 let commit = match cli.api(&commit_path) {
222 Api::Ok(body) => body
223 .pointer(commit_pointer)
224 .and_then(Value::as_str)
225 .map(str::to_owned),
226 Api::Missing => None,
227 Api::Failed(reason) => return unknown(reason),
228 };
229 let Some(commit) = commit else {
230 return milestone(
231 "not-observed",
232 format!("the newest release, {tag}, names no commit the forge serves"),
233 );
234 };
235 let runs_path = match forge {
236 Forge::Github => {
237 let workflow = match driver {
238 "rust" => "release-plz.yml",
239 _ => "release.yml",
240 };
241 format!(
242 "repos/{}/actions/workflows/{workflow}/runs?branch={trunk}&head_sha={commit}&event=push&status=success&per_page=1",
243 ctx.repo
244 )
245 }
246 Forge::Gitlab => format!(
247 "projects/{}/pipelines?ref={trunk}&sha={commit}&status=success&per_page=1",
248 release_state::gitlab_project(&ctx.repo)
249 ),
250 };
251 let ran = match cli.api(&runs_path) {
252 Api::Ok(body) => body["total_count"].as_u64().map_or_else(
253 || body.as_array().is_some_and(|list| !list.is_empty()),
254 |count| count > 0,
255 ),
256 Api::Missing => false,
257 Api::Failed(reason) => return unknown(reason),
258 };
259 if ran {
260 milestone(
261 "observed",
262 format!("a publish run succeeded on {trunk} at the commit {tag} names"),
263 )
264 } else {
265 milestone(
266 "not-observed",
267 format!("the newest release, {tag}, came from no successful publish run on {trunk}"),
268 )
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::{operator_forms, word};
275 use crate::detect::Forge;
276 use crate::release_state::Answer;
277
278 #[test]
279 fn each_registry_form_carries_its_read_back_check() {
280 let rust: Vec<&str> = operator_forms("rust", "widget", Some(Forge::Github), "acme/widget")
281 .iter()
282 .map(|(id, _)| *id)
283 .collect();
284 assert_eq!(
285 rust,
286 [
287 "trusted-publisher",
288 "bootstrap-token-revoked",
289 "trusted-publishing-required"
290 ]
291 );
292 assert!(
293 operator_forms("rust", "widget", Some(Forge::Github), "acme/widget")[0]
294 .1
295 .contains("crates.io/crates/widget/settings")
296 );
297 let github = operator_forms("python", "widget", Some(Forge::Github), "acme/widget");
298 assert_eq!(github.len(), 2);
299 assert!(github[0].1.contains("release.yml"), "{}", github[0].1);
300 assert!(github[0].1.contains("environment pypi"), "{}", github[0].1);
301 let gitlab = operator_forms("python", "widget", Some(Forge::Gitlab), "acme/widget");
302 assert!(gitlab[0].1.contains("acme/widget"), "{}", gitlab[0].1);
303 assert!(gitlab[0].1.contains(".gitlab-ci.yml"), "{}", gitlab[0].1);
304 assert!(operator_forms("bash", "widget", Some(Forge::Github), "acme/widget").is_empty());
305 assert_eq!(word(Answer::Yes), "observed");
306 assert_eq!(word(Answer::No), "not-observed");
307 }
308}