Skip to main content

omni_dev/jev/
citations.rs

1//! Shared citation-finding for the `jev` commands (#1812).
2//!
3//! Recognises an issue/pull-request reference in free text — `#N`, `PR #N` /
4//! `pull request #N` (case-insensitive), `owner/repo#N`, and a full GitHub
5//! issue/pull URL — and resolves it to an [`ItemRef`]. Originally written for
6//! `verify-decision` (#1779) and factored out here so `route`'s `depends_on`
7//! (#1812) can reuse it rather than duplicate the regex and its boundary
8//! rules, which is exactly the kind of "silently diverging" duplication this
9//! project's own optimization notes call out.
10
11use std::collections::BTreeSet;
12use std::ops::Range;
13use std::sync::OnceLock;
14
15use regex::{Captures, Regex};
16
17use crate::provider::{ItemKind, ItemRef};
18
19/// A reference to another issue or pull request found in a comment's text.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Citation {
22    /// What the reference resolves to.
23    pub item_ref: ItemRef,
24    /// The exact text matched, e.g. `"#1614"` or `"PR #1629"` — reused
25    /// verbatim in the splitter prompt, since the splitter is asked to
26    /// write `cites` "as it appears in the comment".
27    pub raw: String,
28    /// Resolved web URL, populated by route's item lookup when available.
29    pub url: Option<String>,
30}
31
32/// One occurrence of a citation, before target-level deduplication.
33///
34/// Route uses the byte range to read the wording around every mention,
35/// including a later explicit dependency after an earlier bare reference.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CitationMention {
38    /// The resolved citation at this occurrence.
39    pub citation: Citation,
40    /// Byte range of the raw reference within the input text.
41    pub range: Range<usize>,
42}
43
44fn citation_regex() -> &'static Regex {
45    static RE: OnceLock<Regex> = OnceLock::new();
46    #[allow(clippy::expect_used)]
47    RE.get_or_init(|| {
48        Regex::new(
49            r"(?ix)
50              https://github\.com/(?P<url_owner>[A-Za-z0-9_.-]+)/(?P<url_repo>[A-Za-z0-9_.-]+)/(?P<url_kind>issues|pull)/(?P<url_num>[0-9]+)
51            | (?P<other_url>https?://\S+)
52            | (?P<or_owner>[A-Za-z0-9_.-]+)/(?P<or_repo>[A-Za-z0-9_.-]+)\#(?P<or_num>[0-9]+)
53            | (?:pr|pull\ request)\s*\#(?P<pr_num>[0-9]+)
54            | \#(?P<issue_num>[0-9]+)
55            ",
56        )
57        .expect("citation regex must compile")
58    })
59}
60
61/// The byte offset just past the matched number, whichever alternative
62/// matched, or `None` for the `other_url` alternative (which matches no
63/// number by design).
64fn number_end(caps: &Captures<'_>) -> Option<usize> {
65    ["url_num", "or_num", "pr_num", "issue_num"]
66        .iter()
67        .find_map(|name| caps.name(name))
68        .map(|m| m.end())
69}
70
71/// Whether the character following a match ending at byte `end` really ends
72/// the reference.
73///
74/// The number must not run straight into a word character. Without this a
75/// hex colour (`#1f77b4`) or a heading anchor (`#1-overview`) parses as
76/// issue `#1` in the current repository — which resolves to a real,
77/// unrelated issue and becomes a source — and a doc link
78/// (`docs/jev.md#4-state-input`) invents the repository `docs/jev.md`,
79/// whose repository-level `NOT_FOUND` fails the whole run.
80fn ends_at_boundary(body: &str, end: usize) -> bool {
81    body[end..]
82        .chars()
83        .next()
84        .is_none_or(|c| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
85}
86
87/// Reads one regex match into `(project, kind hint, number)`. The kind is
88/// only a hint — [`crate::github_issues::fetch_items`] resolves the real
89/// kind from GitHub's `__typename`, since a comment can call a pull
90/// request "#1629" or an issue "PR #1614" and only the API knows which it is.
91fn citation_from_captures(
92    caps: &Captures<'_>,
93    default_project: &str,
94) -> Option<(String, ItemKind, u64)> {
95    if let Some(n) = caps.name("url_num") {
96        let kind = if &caps["url_kind"] == "pull" {
97            ItemKind::ChangeRequest
98        } else {
99            ItemKind::Issue
100        };
101        n.as_str().parse::<u64>().ok().map(|number| {
102            (
103                format!("{}/{}", &caps["url_owner"], &caps["url_repo"]),
104                kind,
105                number,
106            )
107        })
108    } else if let Some(n) = caps.name("or_num") {
109        n.as_str().parse::<u64>().ok().map(|number| {
110            (
111                format!("{}/{}", &caps["or_owner"], &caps["or_repo"]),
112                ItemKind::Issue,
113                number,
114            )
115        })
116    } else if let Some(n) = caps.name("pr_num") {
117        n.as_str()
118            .parse::<u64>()
119            .ok()
120            .map(|number| (default_project.to_string(), ItemKind::ChangeRequest, number))
121    } else if let Some(n) = caps.name("issue_num") {
122        n.as_str()
123            .parse::<u64>()
124            .ok()
125            .map(|number| (default_project.to_string(), ItemKind::Issue, number))
126    } else {
127        // omni-dev: coverage ignore-line reason="unreachable: both call sites (find_citations, first_citation) already filtered out the only alternative with no numbered group (other_url) via number_end before calling this"
128        None
129    }
130}
131
132/// Reads the first citation in `text`, skipping a match that does not end
133/// at a boundary (see [`ends_at_boundary`]) or that is a non-issue URL.
134pub(crate) fn first_citation(text: &str, default_project: &str) -> Option<(String, ItemKind, u64)> {
135    citation_regex().captures_iter(text).find_map(|caps| {
136        let end = number_end(&caps)?;
137        if !ends_at_boundary(text, end) {
138            return None;
139        }
140        citation_from_captures(&caps, default_project)
141    })
142}
143
144/// Finds every issue/pull-request reference in `body`.
145///
146/// Recognises `#N`, `PR #N` / `pull request #N` (case-insensitive),
147/// `owner/repo#N`, and full GitHub issue/pull URLs. Drops a citation of
148/// the judged issue itself, and deduplicates by `(project, number)`,
149/// keeping the first occurrence's raw text.
150///
151/// Scans **all** of `body`, code included: `verify-decision` checks each
152/// citation against its source, so a reference deliberately written in
153/// backticks (`` `#1614` ``) must still be checked, and a missed source
154/// weakens a verifier where an extra one only costs a lookup. `route` skips
155/// code instead, by masking it before it calls this (#2003; see
156/// `route::build_route_citation_text`).
157#[must_use]
158pub fn find_citations(body: &str, default_project: &str, judged: &ItemRef) -> Vec<Citation> {
159    let mut seen = BTreeSet::new();
160    let mut out = Vec::new();
161    for mention in find_citation_mentions(body, default_project, judged) {
162        let citation = mention.citation;
163        if seen.insert((citation.item_ref.project.clone(), citation.item_ref.number)) {
164            out.push(citation);
165        }
166    }
167    out
168}
169
170/// Finds every citation occurrence, retaining repeated references and their
171/// byte ranges. Uses the same matching and boundary rules as
172/// [`find_citations`].
173#[must_use]
174pub fn find_citation_mentions(
175    body: &str,
176    default_project: &str,
177    judged: &ItemRef,
178) -> Vec<CitationMention> {
179    let mut out = Vec::new();
180    for caps in citation_regex().captures_iter(body) {
181        let Some(end) = number_end(&caps) else {
182            continue; // a non-issue URL, matched only so it cannot be mis-read
183        };
184        if !ends_at_boundary(body, end) {
185            continue;
186        }
187        let Some((project, kind, number)) = citation_from_captures(&caps, default_project) else {
188            continue;
189        };
190        if project == judged.project && number == judged.number {
191            continue;
192        }
193        if let Some(matched) = caps.get(0) {
194            out.push(CitationMention {
195                range: matched.range(),
196                citation: Citation {
197                    item_ref: ItemRef {
198                        provider: judged.provider,
199                        project,
200                        kind,
201                        number,
202                    },
203                    raw: caps[0].to_string(),
204                    url: None,
205                },
206            });
207        }
208    }
209    out
210}
211
212#[cfg(test)]
213#[allow(clippy::unwrap_used, clippy::expect_used)]
214mod tests {
215    use super::*;
216    use crate::jev::markdown_code::mask_code;
217    use crate::provider::GitProvider;
218
219    fn judged(number: u64) -> ItemRef {
220        ItemRef {
221            provider: GitProvider::GitHub,
222            project: "rust-works/omni-dev".to_string(),
223            kind: ItemKind::Issue,
224            number,
225        }
226    }
227
228    #[test]
229    fn finds_bare_hash_and_owner_repo_and_url_forms() {
230        let body = "See #1614, other/repo#5, and https://github.com/rust-works/omni-dev/pull/1629.";
231        let cites = find_citations(body, "rust-works/omni-dev", &judged(1779));
232        assert_eq!(cites.len(), 3);
233        assert_eq!(cites[0].item_ref.number, 1614);
234        assert_eq!(cites[0].item_ref.kind, ItemKind::Issue);
235        assert_eq!(cites[1].item_ref.project, "other/repo");
236        assert_eq!(cites[2].item_ref.number, 1629);
237        assert_eq!(cites[2].item_ref.kind, ItemKind::ChangeRequest);
238    }
239
240    #[test]
241    fn a_github_issue_url_resolves_to_issue_kind() {
242        let cites = find_citations(
243            "see https://github.com/rust-works/omni-dev/issues/1614",
244            "rust-works/omni-dev",
245            &judged(1779),
246        );
247        assert_eq!(cites.len(), 1);
248        assert_eq!(cites[0].item_ref.kind, ItemKind::Issue);
249        assert_eq!(cites[0].item_ref.number, 1614);
250    }
251
252    /// A citation number too large for `u64` fails to parse; the citation is
253    /// skipped rather than propagating the parse failure.
254    #[test]
255    fn an_overflowing_citation_number_is_skipped() {
256        let cites = find_citations(
257            "see #99999999999999999999 and #1614",
258            "rust-works/omni-dev",
259            &judged(1779),
260        );
261        assert_eq!(cites.len(), 1);
262        assert_eq!(cites[0].item_ref.number, 1614);
263    }
264
265    #[test]
266    fn pr_prefix_wins_over_the_bare_hash_alternative() {
267        let cites = find_citations("Fixed by PR #1629.", "rust-works/omni-dev", &judged(1));
268        assert_eq!(cites.len(), 1);
269        assert_eq!(cites[0].item_ref.kind, ItemKind::ChangeRequest);
270        assert_eq!(cites[0].raw, "PR #1629");
271    }
272
273    #[test]
274    fn pull_request_prefix_is_case_insensitive() {
275        let cites = find_citations("see pull request #42 for context", "o/r", &judged(1));
276        assert_eq!(cites[0].item_ref.number, 42);
277        assert_eq!(cites[0].item_ref.kind, ItemKind::ChangeRequest);
278    }
279
280    #[test]
281    fn drops_self_citations_and_dedupes() {
282        let cites = find_citations(
283            "#1779 depends on #1614, see also #1614 again",
284            "rust-works/omni-dev",
285            &judged(1779),
286        );
287        assert_eq!(cites.len(), 1);
288        assert_eq!(cites[0].item_ref.number, 1614);
289    }
290
291    #[test]
292    fn mentions_keep_repeated_references_and_byte_ranges() {
293        let body = "See #1614. Later: blocked on #1614.";
294        let mentions = find_citation_mentions(body, "rust-works/omni-dev", &judged(1779));
295        assert_eq!(mentions.len(), 2);
296        assert_eq!(&body[mentions[0].range.clone()], "#1614");
297        assert_eq!(&body[mentions[1].range.clone()], "#1614");
298        assert_eq!(
299            find_citations(body, "rust-works/omni-dev", &judged(1779)).len(),
300            1
301        );
302    }
303
304    #[test]
305    fn returns_nothing_for_a_comment_with_no_citations() {
306        assert!(find_citations("Looks good to me!", "o/r", &judged(1)).is_empty());
307    }
308
309    /// A number that runs into a word character is not a citation. Without
310    /// the boundary check a hex colour or a heading anchor resolved to a
311    /// real, unrelated issue and became a source Jev was asked about.
312    #[test]
313    fn a_number_running_into_a_word_character_is_not_a_citation() {
314        for body in [
315            "the colour #1f77b4 is used",
316            "see the anchor #1-overview below",
317        ] {
318            assert!(
319                find_citations(body, "rust-works/omni-dev", &judged(1779)).is_empty(),
320                "{body}"
321            );
322        }
323    }
324
325    /// A relative doc link is not `owner/repo#N`. Without this the invented
326    /// project `docs/jev.md` reached `gh`, whose repository-level
327    /// `NOT_FOUND` fails the whole run rather than one citation.
328    #[test]
329    fn a_doc_anchor_link_does_not_invent_a_repository() {
330        let cites = find_citations(
331            "see [state input](docs/jev.md#4-state-input) and #1614",
332            "rust-works/omni-dev",
333            &judged(1779),
334        );
335        assert_eq!(cites.len(), 1);
336        assert_eq!(cites[0].item_ref.project, "rust-works/omni-dev");
337        assert_eq!(cites[0].item_ref.number, 1614);
338    }
339
340    /// A URL that is not a GitHub issue/pull link is swallowed whole, so
341    /// neither its path nor its fragment can be read as a citation.
342    #[test]
343    fn a_non_issue_url_yields_no_citation() {
344        for body in [
345            "see https://github.com/rust-works/omni-dev/blob/main/README.md#12",
346            "see https://example.com/browse/X#1614",
347        ] {
348            assert!(
349                find_citations(body, "rust-works/omni-dev", &judged(1779)).is_empty(),
350                "{body}"
351            );
352        }
353    }
354
355    #[test]
356    fn a_citation_followed_by_punctuation_is_still_found() {
357        for body in ["fixed by #1614.", "fixed by #1614", "fixed by (#1614)"] {
358            let cites = find_citations(body, "rust-works/omni-dev", &judged(1779));
359            assert_eq!(cites.len(), 1, "{body}");
360            assert_eq!(cites[0].raw, "#1614", "{body}");
361        }
362    }
363
364    const CODE_BODY: &str = "\
365Real one: #1614.
366
367```
368error: could not find owner/repo#123
369```
370
371Inline `o/r#1` and ``o/r#2`` examples, then PR #1629.
372";
373
374    /// `verify-decision` scans code, so a reference deliberately written in
375    /// backticks is still checked against its source (#2003). `route` skips
376    /// code by masking it first.
377    #[test]
378    fn find_citations_reads_code() {
379        let cites = find_citations(CODE_BODY, "rust-works/omni-dev", &judged(1779));
380        let numbers: Vec<u64> = cites.iter().map(|c| c.item_ref.number).collect();
381        assert_eq!(numbers, [1614, 123, 1, 2, 1629]);
382    }
383
384    #[test]
385    fn a_backticked_reference_is_a_citation() {
386        let cites = find_citations("settled by `#1614`", "rust-works/omni-dev", &judged(1779));
387        assert_eq!(cites.len(), 1);
388        assert_eq!(cites[0].raw, "#1614");
389    }
390
391    /// What `route` does: mask, then find. The quoted examples go, the real
392    /// references stay, and a citation abutting a span is still found.
393    #[test]
394    fn masked_text_keeps_only_the_citations_outside_code() {
395        let masked = mask_code(CODE_BODY);
396        let cites = find_citations(&masked, "rust-works/omni-dev", &judged(1779));
397        let raws: Vec<&str> = cites.iter().map(|c| c.raw.as_str()).collect();
398        assert_eq!(raws, ["#1614", "PR #1629"]);
399        let abutting = mask_code("fixed by #1614`x`");
400        assert_eq!(
401            find_citations(&abutting, "rust-works/omni-dev", &judged(1779))[0].raw,
402            "#1614"
403        );
404    }
405}