Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

437 linesCodeBlameRaw
1//! What `#N` in text names, the one set of rules every reader of references
2//! follows: closing keywords in pull requests and commits (work), Markdown
3//! on the site, chat and the CLI. `packages/contracts/src/refs.ts` mirrors
4//! it.
5//!
6//! A reference is `#N`, `owner/name#N` (read in that repository's context)
7//! or `workspace#N` (in that workspace's). Read in a repository R of
8//! workspace W, `#N` is the first of:
9//!
10//! 1. a pull request of R numbered N;
11//! 2. a work item filed in R whose number there was N (an old issue);
12//! 3. the work item numbered N in W;
13//! 4. a pull request numbered N in another repository of W (new pull
14//! requests are numbered in the workspace, so this is unique).
15//!
16//! Read in a workspace alone, `#N` is the work item N in W, else a pull
17//! request numbered N in W. The work service's `resolve_ref` does the
18//! lookups.
19//!
20//! What sits in code, a `span` or a fenced block, is no reference: it is
21//! usually an example.
22
23use crate::repos::RepoPath;
24
25/// Where a reference is read.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum RefScope {
28 /// `#N`: in the context it is written in.
29 Here,
30 /// `owner/name#N`: in that repository.
31 Repo(RepoPath),
32 /// `workspace#N`: in that workspace.
33 Workspace(String),
34}
35
36/// A reference found in text.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct Ref {
39 pub scope: RefScope,
40 pub number: u32,
41}
42
43impl Ref {
44 /// The repository it is read in, given the one it was written in:
45 /// its own for `owner/name#N`, `here` for `#N`, none for `workspace#N`.
46 pub fn repo<'a>(&'a self, here: Option<&'a RepoPath>) -> Option<&'a RepoPath> {
47 match &self.scope {
48 RefScope::Here => here,
49 RefScope::Repo(repo) => Some(repo),
50 RefScope::Workspace(_) => None,
51 }
52 }
53
54 /// The workspace it is read in, given the one it was written in.
55 pub fn workspace<'a>(&'a self, here: &'a str) -> &'a str {
56 match &self.scope {
57 RefScope::Here => here,
58 RefScope::Repo(repo) => &repo.namespace,
59 RefScope::Workspace(workspace) => workspace,
60 }
61 }
62}
63
64/// One place `#N` may name something, in the order [`lookup_order`] gives.
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum Lookup {
67 /// A pull request of the repository it is read in.
68 PullInRepo,
69 /// A work item filed in that repository, by the number it had there.
70 LegacyItem,
71 /// The work item of the workspace with that number.
72 Item,
73 /// A pull request of another repository of the workspace.
74 PullInWorkspace,
75}
76
77/// Where to look for `#N`, first to last: read in a repository, or in a
78/// workspace alone.
79pub fn lookup_order(in_repo: bool) -> &'static [Lookup] {
80 if in_repo {
81 &[Lookup::PullInRepo, Lookup::LegacyItem, Lookup::Item, Lookup::PullInWorkspace]
82 } else {
83 &[Lookup::Item, Lookup::PullInWorkspace]
84 }
85}
86
87/// The first of `lookup_order(in_repo)` that `found` finds.
88pub fn first_found<T>(in_repo: bool, mut found: impl FnMut(Lookup) -> Option<T>) -> Option<T> {
89 lookup_order(in_repo).iter().find_map(|lookup| found(*lookup))
90}
91
92/// The words that close a work item: `Fixes #12`.
93pub const CLOSING_KEYWORDS: [&str; 9] = ["close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"];
94
95/// The words that tie work to a work item without closing it: `Part of #12`,
96/// `Refs #12`.
97pub const PART_KEYWORDS: [&str; 3] = ["part of", "refs", "ref"];
98
99/// The most references one text closes or is part of.
100pub const MAX_REFS: usize = 50;
101
102/// What a pull request or commit says about the work items it names.
103#[derive(Clone, Debug, Default, PartialEq, Eq)]
104pub struct Named {
105 /// After a closing keyword, in the order named, each once.
106 pub closes: Vec<Ref>,
107 /// After `Part of` or `Refs`, in the order named, each once, leaving
108 /// out what it also closes.
109 pub parts: Vec<Ref>,
110}
111
112/// The references `text` names after closing keywords and after `Part of`
113/// or `Refs`, outside code.
114pub fn named(text: &str) -> Named {
115 let mut named = Named::default();
116 for prose in outside_code(text) {
117 scan(&prose, &mut named);
118 }
119 named.parts.retain(|part| !named.closes.contains(part));
120 named.closes.truncate(MAX_REFS);
121 named.parts.truncate(MAX_REFS);
122 named
123}
124
125/// Every reference in `text` outside code, in order, each once: `#12`,
126/// `owner/name#12` and `workspace#12`, for rendering them as links.
127pub fn refs_in_text(text: &str) -> Vec<Ref> {
128 let mut found: Vec<Ref> = Vec::new();
129 for prose in outside_code(text) {
130 let chars: Vec<char> = prose.chars().collect();
131 let mut i = 0;
132 while i < chars.len() {
133 let starts = i == 0 || !is_name(chars[i - 1]) && chars[i - 1] != '#' && chars[i - 1] != '&';
134 if starts && let Some((reference, after)) = reference_at(&chars, i) {
135 if !found.contains(&reference) {
136 found.push(reference);
137 }
138 i = after;
139 continue;
140 }
141 i += 1;
142 }
143 }
144 found
145}
146
147/// A single reference, the whole of `text`: `#12`, `owner/name#12` or
148/// `workspace#12`.
149pub fn parse_ref(text: &str) -> Option<Ref> {
150 let chars: Vec<char> = text.trim().chars().collect();
151 match reference_at(&chars, 0) {
152 Some((reference, after)) if after == chars.len() => Some(reference),
153 _ => None,
154 }
155}
156
157/// The parts of Markdown outside fenced code blocks and code spans, each
158/// with code replaced by a space so words on either side stay apart.
159pub fn outside_code(text: &str) -> Vec<String> {
160 let mut parts = Vec::new();
161 let mut current = String::new();
162 // The fence that opened the block being skipped: its character and length.
163 let mut fence: Option<(char, usize)> = None;
164 for line in text.lines() {
165 let trimmed = line.trim_start();
166 let opener = ['`', '~'].into_iter().find_map(|mark| {
167 let run = trimmed.chars().take_while(|c| *c == mark).count();
168 (run >= 3).then_some((mark, run))
169 });
170 match (fence, opener) {
171 (None, Some(opened)) => {
172 fence = Some(opened);
173 parts.push(std::mem::take(&mut current));
174 }
175 (Some((mark, length)), Some((closing, run)))
176 if closing == mark && run >= length && trimmed.trim_end().chars().all(|c| c == mark) =>
177 {
178 fence = None;
179 }
180 (Some(_), _) => {}
181 (None, None) => {
182 current.push_str(&without_spans(line));
183 current.push('\n');
184 }
185 }
186 }
187 parts.push(current);
188 parts
189}
190
191/// A line with its code spans (between matching runs of backticks) blanked.
192fn without_spans(line: &str) -> String {
193 let chars: Vec<char> = line.chars().collect();
194 let mut out = String::with_capacity(line.len());
195 let mut i = 0;
196 while i < chars.len() {
197 if chars[i] != '`' {
198 out.push(chars[i]);
199 i += 1;
200 continue;
201 }
202 let run = chars[i..].iter().take_while(|c| **c == '`').count();
203 // The closing run of the same length, if there is one.
204 let mut j = i + run;
205 let mut close = None;
206 while j < chars.len() {
207 if chars[j] == '`' {
208 let other = chars[j..].iter().take_while(|c| **c == '`').count();
209 if other == run {
210 close = Some(j + other);
211 break;
212 }
213 j += other;
214 } else {
215 j += 1;
216 }
217 }
218 match close {
219 Some(end) => {
220 out.push(' ');
221 i = end;
222 }
223 // An unmatched run is literal backticks.
224 None => {
225 out.extend(&chars[i..i + run]);
226 i += run;
227 }
228 }
229 }
230 out
231}
232
233fn is_word(c: char) -> bool {
234 c.is_alphanumeric() || c == '_'
235}
236
237/// A character of a workspace or repository name.
238fn is_name(c: char) -> bool {
239 c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'
240}
241
242/// Every keyword-and-reference pair in `text`.
243fn scan(text: &str, named: &mut Named) {
244 let chars: Vec<char> = text.chars().collect();
245 let mut i = 0;
246 while i < chars.len() {
247 // A word starts here.
248 if !is_word(chars[i]) || (i > 0 && is_word(chars[i - 1])) {
249 i += 1;
250 continue;
251 }
252 let end = chars[i..].iter().position(|c| !is_word(*c)).map_or(chars.len(), |n| i + n);
253 let word: String = chars[i..end].iter().collect::<String>().to_lowercase();
254 let (closing, end) = if CLOSING_KEYWORDS.contains(&word.as_str()) {
255 (true, end)
256 } else if PART_KEYWORDS.contains(&word.as_str()) {
257 (false, end)
258 } else if word == "part" {
259 // `part of`, with spaces between.
260 let spaces = chars[end..].iter().take_while(|c| **c == ' ' || **c == '\t').count();
261 let of: String = chars[(end + spaces).min(chars.len())..].iter().take(2).collect::<String>().to_lowercase();
262 let after = end + spaces + 2;
263 if spaces == 0 || of != "of" || chars.get(after).is_some_and(|c| is_word(*c)) {
264 i = end;
265 continue;
266 }
267 (false, after)
268 } else {
269 i = end;
270 continue;
271 };
272 i = end;
273 // An optional colon, then spaces on the same line; without a colon,
274 // at least one space.
275 let mut at = end;
276 let colon = chars.get(at) == Some(&':');
277 if colon {
278 at += 1;
279 }
280 let spaces = chars[at..].iter().take_while(|c| **c == ' ' || **c == '\t').count();
281 if spaces == 0 && !colon {
282 continue;
283 }
284 at += spaces;
285 if let Some((reference, after)) = reference_at(&chars, at) {
286 let list = if closing { &mut named.closes } else { &mut named.parts };
287 if !list.contains(&reference) {
288 list.push(reference);
289 }
290 i = after;
291 }
292 }
293}
294
295/// A reference at `at`: `#12`, `owner/name#12` or `workspace#12`, ending at
296/// a word boundary. The reference and where it ends.
297fn reference_at(chars: &[char], at: usize) -> Option<(Ref, usize)> {
298 let length = chars[at..].iter().position(|c| *c == '#' || !(is_name(*c) || *c == '/')).unwrap_or(chars.len() - at);
299 let hash = at + length;
300 if chars.get(hash) != Some(&'#') {
301 return None;
302 }
303 let named: String = chars[at..hash].iter().collect();
304 let scope = if named.is_empty() {
305 RefScope::Here
306 } else {
307 match named.split_once('/') {
308 Some((owner, name)) => {
309 if owner.is_empty() || name.is_empty() || name.contains('/') {
310 return None;
311 }
312 RefScope::Repo(RepoPath { namespace: owner.to_lowercase(), name: name.to_owned() })
313 }
314 None => RefScope::Workspace(named.to_lowercase()),
315 }
316 };
317 let digits = chars[hash + 1..].iter().take_while(|c| c.is_ascii_digit()).count();
318 if digits == 0 || digits > 9 {
319 return None;
320 }
321 let after = hash + 1 + digits;
322 if chars.get(after).is_some_and(|c| is_word(*c)) {
323 return None;
324 }
325 let number: u32 = chars[hash + 1..after].iter().collect::<String>().parse().ok()?;
326 (number > 0).then_some((Ref { scope, number }, after))
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 fn here(number: u32) -> Ref {
334 Ref { scope: RefScope::Here, number }
335 }
336
337 fn repo(owner: &str, name: &str, number: u32) -> Ref {
338 Ref { scope: RefScope::Repo(RepoPath { namespace: owner.into(), name: name.into() }), number }
339 }
340
341 fn closes(text: &str) -> Vec<Ref> {
342 named(text).closes
343 }
344
345 #[test]
346 fn every_keyword_closes_whatever_its_case() {
347 for keyword in CLOSING_KEYWORDS {
348 assert_eq!(closes(&format!("{keyword} #7")), vec![here(7)], "{keyword}");
349 assert_eq!(closes(&format!("{} #7", keyword.to_uppercase())), vec![here(7)], "{keyword}");
350 }
351 assert_eq!(closes("Fixes: #12"), vec![here(12)]);
352 assert_eq!(closes("fixes:#12"), vec![here(12)]);
353 assert_eq!(closes("This fixes #3 and resolves #4, closes #3 again."), vec![here(3), here(4)]);
354 }
355
356 #[test]
357 fn only_whole_words_and_whole_numbers() {
358 assert!(closes("prefixes #3").is_empty());
359 assert!(closes("fixesthis #3").is_empty());
360 assert!(closes("fixes#3").is_empty(), "no space and no colon");
361 assert!(closes("fixes #3a").is_empty());
362 assert!(closes("fixes #").is_empty());
363 assert!(closes("fixes #0").is_empty());
364 assert!(closes("fixes\n#3").is_empty(), "on the same line");
365 assert_eq!(closes("(fixes #3)."), vec![here(3)]);
366 assert!(closes("fixes issue #3").is_empty(), "the reference follows the keyword");
367 }
368
369 #[test]
370 fn a_reference_names_its_repository_or_workspace() {
371 assert_eq!(closes("Closes flagon-io/g1t#40"), vec![repo("flagon-io", "g1t", 40)]);
372 assert_eq!(closes("closes Flagon-IO/g1t#40"), vec![repo("flagon-io", "g1t", 40)]);
373 assert_eq!(closes("closes acme#9"), vec![Ref { scope: RefScope::Workspace("acme".into()), number: 9 }]);
374 assert!(closes("closes a/b/c#4").is_empty());
375 assert_eq!(parse_ref("acme/web#3"), Some(repo("acme", "web", 3)));
376 assert_eq!(parse_ref(" #3 "), Some(here(3)));
377 assert_eq!(parse_ref("#3 and more"), None);
378 }
379
380 #[test]
381 fn part_of_and_refs_link_without_closing() {
382 let found = named("Part of #12, refs acme/api#3. Fixes #4; part of #4");
383 assert_eq!(found.closes, vec![here(4)]);
384 assert_eq!(found.parts, vec![here(12), repo("acme", "api", 3)]);
385 assert!(named("partly #3").parts.is_empty());
386 assert!(named("part #3").parts.is_empty());
387 assert_eq!(named("Part of: #8").parts, vec![here(8)]);
388 }
389
390 #[test]
391 fn code_says_nothing() {
392 assert!(closes("Write `fixes #3` to close a work item.").is_empty());
393 assert!(closes("Write ``fixes #3 `x` `` here.").is_empty());
394 assert!(closes("```\nfixes #3\n```").is_empty());
395 assert!(closes("~~~md\nfixes #3\n~~~").is_empty());
396 assert_eq!(closes("```\nfixes #3\n```\nfixes #4"), vec![here(4)]);
397 // An unclosed span is just a backtick.
398 assert_eq!(closes("a ` fixes #5"), vec![here(5)]);
399 // An unclosed fence hides the rest.
400 assert!(closes("````\nfixes #6\n```\nfixes #7").is_empty());
401 }
402
403 #[test]
404 fn no_more_than_fifty() {
405 let many: String = (1..=80).map(|n| format!("fixes #{n} ")).collect();
406 assert_eq!(closes(&many).len(), MAX_REFS);
407 }
408
409 #[test]
410 fn references_in_text_are_found_once_each() {
411 let text = "See #4, acme/web#7 and acme#9; not &#39;, #4a or `#5`. #4 again.";
412 assert_eq!(
413 refs_in_text(text),
414 vec![here(4), repo("acme", "web", 7), Ref { scope: RefScope::Workspace("acme".into()), number: 9 }]
415 );
416 }
417
418 #[test]
419 fn in_a_repository_its_own_pull_requests_and_old_issues_come_first() {
420 assert_eq!(
421 lookup_order(true),
422 [Lookup::PullInRepo, Lookup::LegacyItem, Lookup::Item, Lookup::PullInWorkspace]
423 );
424 assert_eq!(lookup_order(false), [Lookup::Item, Lookup::PullInWorkspace]);
425 // An old issue of the repository wins over the workspace's item of
426 // the same number; a pull request of the repository over both.
427 let found = |have: &[Lookup]| first_found(true, |lookup| have.contains(&lookup).then_some(lookup));
428 assert_eq!(found(&[Lookup::Item, Lookup::LegacyItem]), Some(Lookup::LegacyItem));
429 assert_eq!(found(&[Lookup::Item, Lookup::PullInRepo, Lookup::LegacyItem]), Some(Lookup::PullInRepo));
430 assert_eq!(found(&[Lookup::PullInWorkspace]), Some(Lookup::PullInWorkspace));
431 assert_eq!(found(&[]), None);
432 let written = repo("acme", "api", 3);
433 let web = RepoPath { namespace: "acme".into(), name: "web".into() };
434 assert_eq!(written.repo(Some(&web)).map(|r| r.name.as_str()), Some("api"));
435 assert_eq!(here(3).workspace("acme"), "acme");
436 }
437}