1use std::collections::{BTreeMap, HashMap};
5
6use crate::context::IndexContext;
7use crate::hosting::Provider;
8use crate::index::{IndexedRepo, RepoLogs};
9
10const BUDGET: usize = 200;
12
13pub async fn resolve(ctx: &IndexContext, heads: &[IndexedRepo], logs: &mut RepoLogs) {
15 if let Ok(old) = crate::index::read_repo_logs(&ctx.graph()).await {
16 for (repo, log) in old {
17 if let Some(now) = logs.get_mut(&repo) {
18 now.landed = log.landed;
19 }
20 }
21 }
22 let Ok(ledger) = crate::ledger::Ledger::open(&ctx.root) else {
23 return;
24 };
25 let records = ledger.all().await.unwrap_or_default();
26 let mut wanted: BTreeMap<(String, String), Option<i64>> = BTreeMap::new();
28 for r in &records {
29 for (ev, re) in r.evidence.iter().filter_map(|e| Some((e, reference(e)?))) {
30 for a in r.anchors.iter().filter(|a| re.names(&a.repo)) {
31 let saw = logs.get(&a.repo).and_then(|l| {
32 let c = a.commit.as_deref()?;
33 l.commits.iter().find(|x| x.sha == c).map(|x| x.time)
34 });
35 let slot = wanted.entry((a.repo.clone(), ev.clone())).or_insert(saw);
36 *slot = match (*slot, saw) {
37 (Some(x), Some(y)) => Some(x.min(y)),
38 (x, y) => x.or(y),
39 };
40 }
41 }
42 }
43 let remotes: HashMap<&str, &str> = heads
44 .iter()
45 .filter_map(|h| Some((h.name.as_str(), h.remote.as_deref()?)))
46 .collect();
47 let client = match reqwest::Client::builder()
48 .user_agent(concat!("comindly/", env!("CARGO_PKG_VERSION")))
49 .timeout(std::time::Duration::from_secs(20))
50 .build()
51 {
52 Ok(c) => c,
53 Err(_) => return,
54 };
55 let (mut asked, mut found, mut failed) = (0, 0, None);
56 for ((repo, ev), saw) in wanted {
57 let Some(log) = logs.get_mut(&repo) else {
58 continue;
59 };
60 let on_main = matches!(reference(&ev), Some(Ref::Commit(_, sha)) if log.commits.iter().any(|c| c.sha.starts_with(sha)));
61 if on_main || log.landed.contains_key(&ev) || asked >= BUDGET {
62 continue;
63 }
64 let Some(project) = remotes.get(repo.as_str()).and_then(|r| project(r, ctx)) else {
65 continue;
66 };
67 asked += 1;
68 match project.landed(&client, &ev).await {
69 Ok(Some((_, at))) if saw.is_some_and(|s| at < s) => {
71 log.landed.insert(ev, Vec::new());
72 }
73 Ok(Some((shas, _))) if !shas.is_empty() => {
74 found += 1;
75 log.landed.insert(ev, shas);
76 }
77 Ok(_) => {}
78 Err(e) => failed = Some(format!("{e:#}")),
79 }
80 }
81 if found > 0 {
82 ctx.report.note(format!(
83 "evidence: {found} merge request(s) or branch commit(s) followed to the default branch"
84 ));
85 }
86 if let Some(e) = failed {
87 ctx.report.warn(format!("evidence lookup: {e}"));
88 }
89}
90
91pub(crate) enum Ref<'a> {
94 Request(Option<&'a str>, u64),
95 Commit(Option<&'a str>, &'a str),
96}
97
98impl Ref<'_> {
99 pub(crate) fn names(&self, repo: &str) -> bool {
101 let (Ref::Request(q, _) | Ref::Commit(q, _)) = self;
102 q.is_none_or(|q| {
103 let q = q.trim_end_matches(".git");
104 q.eq_ignore_ascii_case(repo)
105 || q.rsplit('/')
106 .next()
107 .is_some_and(|l| l.eq_ignore_ascii_case(repo))
108 })
109 }
110}
111
112fn qualifier(q: &str) -> Option<Option<&str>> {
113 let q = q.trim();
114 if q.is_empty() {
115 return Some(None);
116 }
117 q.chars()
118 .all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
119 .then_some(Some(q))
120}
121
122pub(crate) fn reference(ev: &str) -> Option<Ref<'_>> {
123 let ev = ev.trim();
124 for (prefix, sep) in [("mr:", '!'), ("pr:", '#')] {
125 if let Some(rest) = ev.strip_prefix(prefix) {
126 let (q, n) = rest.rsplit_once(sep).unwrap_or(("", rest));
127 return Some(Ref::Request(qualifier(q)?, n.trim().parse().ok()?));
128 }
129 }
130 let rest = ev.strip_prefix("commit:")?.trim();
131 let (q, sha) = rest.rsplit_once('@').unwrap_or(("", rest));
132 let sha = sha.trim();
133 (sha.len() >= 7 && sha.chars().all(|c| c.is_ascii_hexdigit()))
134 .then_some(Ref::Commit(qualifier(q)?, sha))
135}
136
137struct Project {
138 provider: Provider,
139 api: String,
140 token: String,
141}
142
143fn project(remote: &str, ctx: &IndexContext) -> Option<Project> {
144 let src = crate::hosting::parse(remote).ok()?;
145 let name = src.repo.clone()?;
146 let token = ctx.git.token(&src.host)?;
147 let api = match src.provider {
148 Provider::GitLab => format!(
149 "https://{}/api/v4/projects/{}",
150 src.host,
151 format!("{}/{name}", src.owner).replace('/', "%2F")
152 ),
153 Provider::GitHub if src.host == "github.com" => {
154 format!("https://api.github.com/repos/{}/{name}", src.owner)
155 }
156 Provider::GitHub => format!("https://{}/api/v3/repos/{}/{name}", src.host, src.owner),
157 };
158 Some(Project {
159 provider: src.provider,
160 api,
161 token,
162 })
163}
164
165impl Project {
166 async fn get(
167 &self,
168 client: &reqwest::Client,
169 path: &str,
170 ) -> anyhow::Result<Option<serde_json::Value>> {
171 let req = client.get(format!("{}{path}", self.api));
172 let req = match self.provider {
173 Provider::GitLab => req.header("PRIVATE-TOKEN", &self.token),
174 Provider::GitHub => req
175 .bearer_auth(&self.token)
176 .header("Accept", "application/vnd.github+json"),
177 };
178 let resp = req.send().await?;
179 if resp.status().as_u16() == 404 {
180 return Ok(None);
181 }
182 anyhow::ensure!(
183 resp.status().is_success(),
184 "{} answered {}",
185 self.api.split('/').nth(2).unwrap_or_default(),
186 resp.status()
187 );
188 Ok(Some(resp.json().await?))
189 }
190
191 async fn list(
192 &self,
193 client: &reqwest::Client,
194 path: &str,
195 ) -> anyhow::Result<Vec<serde_json::Value>> {
196 Ok(match self.get(client, path).await? {
197 Some(serde_json::Value::Array(v)) => v,
198 _ => Vec::new(),
199 })
200 }
201
202 async fn landed(
205 &self,
206 client: &reqwest::Client,
207 ev: &str,
208 ) -> anyhow::Result<Option<(Vec<String>, i64)>> {
209 let gitlab = self.provider == Provider::GitLab;
210 let number = match reference(ev) {
211 Some(Ref::Request(_, n)) => n,
212 Some(Ref::Commit(_, sha)) => {
213 let path = if gitlab {
214 format!("/repository/commits/{sha}/merge_requests")
215 } else {
216 format!("/commits/{sha}/pulls")
217 };
218 let merged = self.list(client, &path).await?.into_iter().find(|m| {
219 if gitlab {
220 m["state"] == "merged"
221 } else {
222 !m["merged_at"].is_null()
223 }
224 });
225 match merged.and_then(|m| m[if gitlab { "iid" } else { "number" }].as_u64()) {
226 Some(n) => n,
227 None => return Ok(None),
228 }
229 }
230 None => return Ok(None),
231 };
232 let path = if gitlab {
233 format!("/merge_requests/{number}")
234 } else {
235 format!("/pulls/{number}")
236 };
237 let Some(req) = self.get(client, &path).await? else {
238 return Ok(None);
239 };
240 let merged = req["merged_at"]
241 .as_str()
242 .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
243 .map(|t| t.timestamp());
244 let Some(at) = merged.filter(|_| !gitlab || req["state"] == "merged") else {
245 return Ok(None);
246 };
247 let keys: &[&str] = if gitlab {
249 &["squash_commit_sha", "merge_commit_sha"]
250 } else {
251 &["merge_commit_sha"]
252 };
253 let mut out: Vec<String> = keys
254 .iter()
255 .find_map(|k| req[*k].as_str())
256 .map(|s| vec![s.to_string()])
257 .unwrap_or_default();
258 if gitlab && out.is_empty() {
259 out = self
261 .list(
262 client,
263 &format!("/merge_requests/{number}/commits?per_page=100"),
264 )
265 .await?
266 .iter()
267 .filter_map(|c| c["id"].as_str().map(str::to_string))
268 .collect();
269 }
270 Ok(Some((out, at)))
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn only_merge_requests_and_commit_shas_are_looked_up() {
280 assert!(matches!(
281 reference("mr:!476"),
282 Some(Ref::Request(None, 476))
283 ));
284 assert!(matches!(reference("pr:#12"), Some(Ref::Request(None, 12))));
285 assert!(matches!(reference("mr:476"), Some(Ref::Request(None, 476))));
286 assert!(matches!(
287 reference("commit:51a59d4"),
288 Some(Ref::Commit(None, "51a59d4"))
289 ));
290 assert!(reference("commit:abc").is_none());
291 assert!(reference("test:t::retry").is_none());
292 assert!(reference("dev logs 2026-11-11").is_none());
293 assert!(reference("mr:see the thread").is_none());
294 }
295
296 #[test]
297 fn evidence_can_name_its_repository() {
298 assert!(matches!(
299 reference("mr:billing-api!472"),
300 Some(Ref::Request(Some("billing-api"), 472))
301 ));
302 assert!(matches!(
303 reference("pr:acme/web#12"),
304 Some(Ref::Request(Some("acme/web"), 12))
305 ));
306 assert!(matches!(
307 reference("commit:web@51a59d4"),
308 Some(Ref::Commit(Some("web"), "51a59d4"))
309 ));
310 let r = reference("mr:group/billing-api!472").unwrap();
311 assert!(r.names("billing-api") && !r.names("web"));
312 assert!(reference("mr:!1").unwrap().names("web"));
313 assert!(reference("mr:a b!1").is_none());
314 }
315}