| 1 | //! What an event names, for the inbox (`g1t_contracts::inbox`): the issue |
| 2 | //! or pull request, its people, and the comment, read straight from the |
| 3 | //! rows. The events service asks as each event arrives and decides from |
| 4 | //! the answer who is told; nobody's access is checked here, so nothing it |
| 5 | //! returns is shown to anyone but the people it names, or to those who can |
| 6 | //! read the repository. |
| 7 | |
| 8 | use g1t_contracts::credentials::Principal; |
| 9 | use g1t_contracts::inbox::{InboxComment, InboxSubject, InboxSubjectArgs, SubjectKind}; |
| 10 | use g1t_contracts::is_valid_namespace; |
| 11 | use g1t_contracts::work::{Comment, CommentKind, Issue, Pull}; |
| 12 | use worker::Result; |
| 13 | |
| 14 | use crate::Work; |
| 15 | use crate::mentions::spoken; |
| 16 | use crate::rows::CommentRow; |
| 17 | |
| 18 | /// The most people one comment notifies by name. |
| 19 | const MAX_MENTIONS: usize = 20; |
| 20 | /// About one line of a comment, shown under its title. |
| 21 | const EXCERPT_CHARS: usize = 140; |
| 22 | |
| 23 | /// The people a comment mentions by name in its own words (not in code or |
| 24 | /// a quote), lowercased, each once. Not an email address, a package scope |
| 25 | /// or a path, and never a reserved name such as `g1t`. |
| 26 | pub(crate) fn people_mentioned(body: &str) -> Vec<String> { |
| 27 | let text = spoken(body); |
| 28 | let chars: Vec<char> = text.chars().collect(); |
| 29 | let mut people: Vec<String> = Vec::new(); |
| 30 | let mut at = 0; |
| 31 | while at < chars.len() && people.len() < MAX_MENTIONS { |
| 32 | if chars[at] != '@' { |
| 33 | at += 1; |
| 34 | continue; |
| 35 | } |
| 36 | let starts_clean = at == 0 || { |
| 37 | let before = chars[at - 1]; |
| 38 | !(before.is_alphanumeric() || "._%+-/\\@`=".contains(before)) |
| 39 | }; |
| 40 | let name: String = chars[at + 1..] |
| 41 | .iter() |
| 42 | .take_while(|c| c.is_ascii_alphanumeric() || **c == '-') |
| 43 | .collect(); |
| 44 | let end = at + 1 + name.chars().count(); |
| 45 | // A path or a package such as `@scope/name`, or a domain. |
| 46 | let ends_clean = match chars.get(end) { |
| 47 | None => true, |
| 48 | Some(c) if "_@/\\".contains(*c) => false, |
| 49 | Some('.') => chars.get(end + 1).is_none_or(|c| !c.is_alphanumeric()), |
| 50 | Some(_) => true, |
| 51 | }; |
| 52 | let name = name.to_lowercase(); |
| 53 | if starts_clean && ends_clean && is_valid_namespace(&name) && !people.contains(&name) { |
| 54 | people.push(name); |
| 55 | } |
| 56 | at = end.max(at + 1); |
| 57 | } |
| 58 | people |
| 59 | } |
| 60 | |
| 61 | /// The first line a comment says something on, cut to about a line. |
| 62 | pub(crate) fn excerpt(body: &str) -> String { |
| 63 | let line = body |
| 64 | .lines() |
| 65 | .map(str::trim) |
| 66 | .find(|line| !line.is_empty() && !line.starts_with("```") && !line.starts_with('>')) |
| 67 | .unwrap_or_default(); |
| 68 | let line = line.split_whitespace().collect::<Vec<_>>().join(" "); |
| 69 | if line.chars().count() <= EXCERPT_CHARS { |
| 70 | return line; |
| 71 | } |
| 72 | let cut: String = line.chars().take(EXCERPT_CHARS - 1).collect(); |
| 73 | format!("{}…", cut.trim_end()) |
| 74 | } |
| 75 | |
| 76 | fn principal(id: &str, username: &str) -> Principal { |
| 77 | Principal { |
| 78 | id: id.to_owned(), |
| 79 | username: username.to_lowercase(), |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | fn of_issue(issue: Issue) -> InboxSubject { |
| 84 | InboxSubject { |
| 85 | kind: Some(SubjectKind::Issue), |
| 86 | title: issue.title, |
| 87 | author: principal(&issue.author.id, &issue.author.username), |
| 88 | requested_by: issue.requested_by.map(|user| principal(&user.id, &user.username)), |
| 89 | assignees: issue.assignees, |
| 90 | ..InboxSubject::default() |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | fn of_pull(pull: Pull, issue: Option<Issue>) -> InboxSubject { |
| 95 | InboxSubject { |
| 96 | kind: Some(SubjectKind::Pull), |
| 97 | title: pull.title, |
| 98 | author: principal(&pull.author.id, &pull.author.username), |
| 99 | requested_by: pull.requested_by.map(|user| principal(&user.id, &user.username)), |
| 100 | assignees: pull.assignees, |
| 101 | reviewers: pull.reviewers, |
| 102 | issue: issue.map(|issue| Box::new(of_issue(issue))), |
| 103 | comment: None, |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | fn of_comment(comment: Comment) -> InboxComment { |
| 108 | let event = comment.kind == CommentKind::Event; |
| 109 | InboxComment { |
| 110 | author: principal(&comment.author.id, &comment.author.username), |
| 111 | excerpt: excerpt(&comment.body), |
| 112 | mentions: if event { Vec::new() } else { people_mentioned(&comment.body) }, |
| 113 | verdict: comment.verdict.map(|verdict| verdict.as_str().to_owned()), |
| 114 | event, |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | impl Work { |
| 119 | /// The issue or pull request numbered `number`, with its people, and |
| 120 | /// the comment asked about. None when there is no such issue or pull |
| 121 | /// request. |
| 122 | pub(crate) async fn inbox_subject(&self, a: InboxSubjectArgs) -> Result<Option<InboxSubject>> { |
| 123 | let comment = async { |
| 124 | let Some(id) = &a.comment_id else { |
| 125 | return Ok::<_, worker::Error>(None); |
| 126 | }; |
| 127 | Ok(self |
| 128 | .db |
| 129 | .prepare("SELECT * FROM comments WHERE id = ? AND repo_id = ?") |
| 130 | .bind(&[id.as_str().into(), a.repo_id.as_str().into()])? |
| 131 | .first::<CommentRow>(None) |
| 132 | .await? |
| 133 | .map(|row| of_comment(Comment::from(row)))) |
| 134 | }; |
| 135 | let (pull, comment) = futures_util::future::try_join(self.pull(&a.repo_id, a.number), comment).await?; |
| 136 | let mut subject = match pull { |
| 137 | Some(pull) => { |
| 138 | let issue = match pull.issue { |
| 139 | Some(number) => self.issue(&a.repo_id, number).await?, |
| 140 | None => None, |
| 141 | }; |
| 142 | of_pull(pull, issue) |
| 143 | } |
| 144 | None => match self.issue(&a.repo_id, a.number).await? { |
| 145 | Some(issue) => of_issue(issue), |
| 146 | None => return Ok(None), |
| 147 | }, |
| 148 | }; |
| 149 | subject.comment = comment; |
| 150 | Ok(Some(subject)) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | #[cfg(test)] |
| 155 | mod tests { |
| 156 | use super::*; |
| 157 | |
| 158 | #[test] |
| 159 | fn people_are_mentioned_by_name_in_their_own_words() { |
| 160 | assert_eq!(people_mentioned("@ana can you look? cc @Bob-1."), vec!["ana", "bob-1"]); |
| 161 | // Once each, never g1t, never in code or a quote. |
| 162 | assert_eq!(people_mentioned("@ana @ana @g1t `@carl`\n> @dee said"), vec!["ana"]); |
| 163 | // Not an address, a package, a path or a domain. |
| 164 | assert!(people_mentioned("ops@ana.dev, @scope/pkg, a/@b, @ana.dev").is_empty()); |
| 165 | assert_eq!(people_mentioned("(@ana)"), vec!["ana"]); |
| 166 | } |
| 167 | |
| 168 | #[test] |
| 169 | fn an_excerpt_is_the_first_line_said() { |
| 170 | assert_eq!(excerpt("\n\n```\ncode\n```"), "code"); |
| 171 | assert_eq!(excerpt("> quoted\n Looks good \nmore"), "Looks good"); |
| 172 | let long = "word ".repeat(60); |
| 173 | let cut = excerpt(&long); |
| 174 | assert!(cut.ends_with('…')); |
| 175 | assert_eq!(cut.chars().count(), EXCERPT_CHARS); |
| 176 | } |
| 177 | } |