Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Inbox: the events service tells people what needs them as events arrive | 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 | } |