Skip to content

g1t/services/work/src/inbox.rs

177 lines6,680 bytesCodeBlame
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
8use g1t_contracts::credentials::Principal;
9use g1t_contracts::inbox::{InboxComment, InboxSubject, InboxSubjectArgs, SubjectKind};
10use g1t_contracts::is_valid_namespace;
11use g1t_contracts::work::{Comment, CommentKind, Issue, Pull};
12use worker::Result;
13
14use crate::Work;
15use crate::mentions::spoken;
16use crate::rows::CommentRow;
17
18/// The most people one comment notifies by name.
19const MAX_MENTIONS: usize = 20;
20/// About one line of a comment, shown under its title.
21const 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`.
26pub(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.
62pub(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
76fn principal(id: &str, username: &str) -> Principal {
77 Principal {
78 id: id.to_owned(),
79 username: username.to_lowercase(),
80 }
81}
82
83fn 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
94fn 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
107fn 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
118impl 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)]
155mod 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}