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.

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