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(driver: &str, package: &str) -> Vec<(&'static str, String)> {
58 match driver {
59 "rust" => vec![
60 (
61 "trusted-publisher",
62 format!(
63 "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"
64 ),
65 ),
66 (
67 "bootstrap-token-revoked",
68 "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(),
69 ),
70 (
71 "trusted-publishing-required",
72 format!(
73 "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"
74 ),
75 ),
76 ],
77 "python" => vec![
78 (
79 "trusted-publisher",
80 format!(
81 "rk binding python, setup specifics: the publishing settings of {package} on PyPI list the owner, the repository, and release-please.yml"
82 ),
83 ),
84 (
85 "bootstrap-token-revoked",
86 "rk binding python, setup specifics: the project-scoped API token is gone from the PyPI account's API tokens".to_owned(),
87 ),
88 ],
89 _ => Vec::new(),
90 }
91}
92
93#[must_use]
95pub fn observe(ctx: &Ctx) -> Vec<Milestone> {
96 let Some(driver) = ctx.driver().filter(|_| ctx.automatic_release()) else {
97 let reason = "the target selects no automated release".to_owned();
98 return vec![
99 Milestone {
100 id: FIRST_VERSION,
101 status: "not-applicable",
102 detail: reason.clone(),
103 },
104 Milestone {
105 id: AUTOMATED_RELEASE,
106 status: "not-applicable",
107 detail: reason,
108 },
109 ];
110 };
111 let registry = match driver {
112 "rust" => Some(Registry::CratesIo),
113 "python" => Some(Registry::Pypi),
114 _ => None,
115 };
116 let package = release_state::package_name(ctx.target.as_std_path(), driver);
117 let mut found = vec![first_version(registry, package.as_deref())];
118 found.push(automated_release(ctx, driver));
119 if let Some(package) = package.as_deref() {
120 for (id, check) in operator_forms(driver, package) {
121 found.push(Milestone {
122 id,
123 status: "operator",
124 detail: check,
125 });
126 }
127 }
128 found
129}
130
131fn first_version(registry: Option<Registry>, package: Option<&str>) -> Milestone {
132 let (status, detail) = match (registry, package) {
133 (None, _) => (
134 "not-applicable",
135 "the driver publishes to no registry; the release page is the distribution".to_owned(),
136 ),
137 (Some(_), None) => (
138 "unknown",
139 "the version file names no package the registry can be asked about".to_owned(),
140 ),
141 (Some(registry), Some(package)) => {
142 let answer = release_state::registry_serves(registry, package, None);
143 (
144 word(answer),
145 format!("{} answers for {package}", registry.url(package, None)),
146 )
147 }
148 };
149 Milestone {
150 id: FIRST_VERSION,
151 status,
152 detail,
153 }
154}
155
156fn automated_release(ctx: &Ctx, driver: &str) -> Milestone {
157 let milestone = |status, detail: String| Milestone {
158 id: AUTOMATED_RELEASE,
159 status,
160 detail,
161 };
162 let Some(forge) = ctx.forge else {
163 return milestone(
164 "not-applicable",
165 "the target names no forge this release reads".to_owned(),
166 );
167 };
168 if ctx.repo.is_empty() {
169 return milestone("unknown", "the target names no forge repository".to_owned());
170 }
171 let cli = ForgeCli::new(ctx.cli.clone(), ctx.target.as_std_path().to_path_buf());
172 let trunk = ctx.trunk();
173 let unknown =
174 |reason: String| milestone("unknown", format!("the forge did not answer: {reason}"));
175 let releases_path = match forge {
179 Forge::Github => format!("repos/{}/releases?per_page=1", ctx.repo),
180 Forge::Gitlab => format!(
181 "projects/{}/releases?per_page=1",
182 release_state::gitlab_project(&ctx.repo)
183 ),
184 };
185 let tag = match cli.api(&releases_path) {
186 Api::Ok(body) => body
187 .as_array()
188 .and_then(|list| list.first())
189 .and_then(|release| release["tag_name"].as_str())
190 .map(str::to_owned),
191 Api::Missing => None,
192 Api::Failed(reason) => return unknown(reason),
193 };
194 let Some(tag) = tag else {
195 return milestone("not-observed", "no release exists yet".to_owned());
196 };
197 let (commit_path, commit_pointer) = match forge {
198 Forge::Github => (format!("repos/{}/commits/{tag}", ctx.repo), "/sha"),
199 Forge::Gitlab => (
200 format!(
201 "projects/{}/repository/tags/{tag}",
202 release_state::gitlab_project(&ctx.repo)
203 ),
204 "/commit/id",
205 ),
206 };
207 let commit = match cli.api(&commit_path) {
208 Api::Ok(body) => body
209 .pointer(commit_pointer)
210 .and_then(Value::as_str)
211 .map(str::to_owned),
212 Api::Missing => None,
213 Api::Failed(reason) => return unknown(reason),
214 };
215 let Some(commit) = commit else {
216 return milestone(
217 "not-observed",
218 format!("the newest release, {tag}, names no commit the forge serves"),
219 );
220 };
221 let runs_path = match forge {
222 Forge::Github => {
223 let workflow = match driver {
224 "rust" => "release-plz.yml",
225 "python" => "release-please.yml",
226 _ => "release.yml",
227 };
228 format!(
229 "repos/{}/actions/workflows/{workflow}/runs?branch={trunk}&head_sha={commit}&event=push&status=success&per_page=1",
230 ctx.repo
231 )
232 }
233 Forge::Gitlab => format!(
234 "projects/{}/pipelines?ref={trunk}&sha={commit}&status=success&per_page=1",
235 release_state::gitlab_project(&ctx.repo)
236 ),
237 };
238 let ran = match cli.api(&runs_path) {
239 Api::Ok(body) => body["total_count"].as_u64().map_or_else(
240 || body.as_array().is_some_and(|list| !list.is_empty()),
241 |count| count > 0,
242 ),
243 Api::Missing => false,
244 Api::Failed(reason) => return unknown(reason),
245 };
246 if ran {
247 milestone(
248 "observed",
249 format!("a publish run succeeded on {trunk} at the commit {tag} names"),
250 )
251 } else {
252 milestone(
253 "not-observed",
254 format!("the newest release, {tag}, came from no successful publish run on {trunk}"),
255 )
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::{operator_forms, word};
262 use crate::release_state::Answer;
263
264 #[test]
265 fn each_registry_form_carries_its_read_back_check() {
266 let rust: Vec<&str> = operator_forms("rust", "widget")
267 .iter()
268 .map(|(id, _)| *id)
269 .collect();
270 assert_eq!(
271 rust,
272 [
273 "trusted-publisher",
274 "bootstrap-token-revoked",
275 "trusted-publishing-required"
276 ]
277 );
278 assert!(
279 operator_forms("rust", "widget")[0]
280 .1
281 .contains("crates.io/crates/widget/settings")
282 );
283 assert_eq!(operator_forms("python", "widget").len(), 2);
284 assert!(operator_forms("bash", "widget").is_empty());
285 assert_eq!(word(Answer::Yes), "observed");
286 assert_eq!(word(Answer::No), "not-observed");
287 }
288}