Skip to main content

comindly_core/
landed.rs

1//! Where the merge and pull requests and branch commits cited as evidence landed on the default branch
2//! (GitLab and GitHub).
3
4use std::collections::{BTreeMap, HashMap};
5
6use crate::context::IndexContext;
7use crate::hosting::Provider;
8use crate::index::{IndexedRepo, RepoLogs};
9
10/// Lookups per indexing run; the rest wait for the next run.
11const BUDGET: usize = 200;
12
13/// Carry earlier answers forward and look up the evidence on records that is not on the default branch yet.
14pub 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    // Each cited request per anchored repository, with the earliest code a record citing it saw.
27    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            // A request merged before the record's code was indexed is another one with that number.
70            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
91/// A merge or pull request (`mr:!N`, `pr:#N`) or commit, optionally naming its repository:
92/// `mr:repo!N`, `pr:owner/repo#N`, `commit:repo@sha`.
93pub(crate) enum Ref<'a> {
94    Request(Option<&'a str>, u64),
95    Commit(Option<&'a str>, &'a str),
96}
97
98impl Ref<'_> {
99    /// Whether this evidence can be about `repo` (unqualified evidence is about every anchored one).
100    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    /// The default-branch commits a merged request, or the request a branch commit belongs to, became,
203    /// and when it was merged; `None` while it is not merged.
204    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        // GitHub's merge_commit_sha is the merge or squash commit, or the last rebased commit.
248        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            // Fast-forward without squash: the MR's own commits are on the default branch.
260            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}