Skip to content

g1t/services/work/src/inbox.rs

311 lines12,402 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, TeamMentioned};
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 teams a comment mentions in its own words, as `workspace/slug`,
62/// each once: `@acme/backend`, not in code, a quote, an address or a path
63/// such as `@acme/backend/x` or `npm i @scope/pkg@1`.
64pub(crate) fn teams_mentioned(body: &str) -> Vec<String> {
65 let text = spoken(body);
66 let chars: Vec<char> = text.chars().collect();
67 let mut teams: Vec<String> = Vec::new();
68 let mut at = 0;
69 while at < chars.len() && teams.len() < MAX_MENTIONS {
70 if chars[at] != '@' {
71 at += 1;
72 continue;
73 }
74 let starts_clean = at == 0 || {
75 let before = chars[at - 1];
76 !(before.is_alphanumeric() || "._%+-/\\@`=".contains(before))
77 };
78 let name: String = chars[at + 1..]
79 .iter()
80 .take_while(|c| c.is_ascii_alphanumeric() || **c == '-' || **c == '/')
81 .collect();
82 let end = at + 1 + name.chars().count();
83 let ends_clean = match chars.get(end) {
84 None => true,
85 Some(c) if "_@\\".contains(*c) => false,
86 Some('.') => chars.get(end + 1).is_none_or(|c| !c.is_alphanumeric()),
87 Some(_) => true,
88 };
89 if starts_clean
90 && ends_clean
91 && name.matches('/').count() == 1
92 && let Some(team) = crate::team_reviews::team_name(&name)
93 && !teams.contains(&team)
94 {
95 teams.push(team);
96 }
97 at = end.max(at + 1);
98 }
99 teams
100}
101
102/// Who each team mentioned tells: everyone in it and its child teams, when
103/// the writer may see the team and it has notifications on.
104pub(crate) fn told_of(teams: &[g1t_contracts::teams::ResolvedTeam], writer: &str) -> Vec<TeamMentioned> {
105 teams
106 .iter()
107 .map(|team| TeamMentioned {
108 team: format!("{}/{}", team.workspace, team.slug),
109 members: if team.notify && team.asker_sees {
110 team.everyone()
111 .map(|person| person.username.clone())
112 .filter(|name| !name.eq_ignore_ascii_case(writer))
113 .collect()
114 } else {
115 Vec::new()
116 },
117 })
118 .collect()
119}
120
121/// The first line a comment says something on, cut to about a line.
122pub(crate) fn excerpt(body: &str) -> String {
123 let line = body
124 .lines()
125 .map(str::trim)
126 .find(|line| !line.is_empty() && !line.starts_with("```") && !line.starts_with('>'))
127 .unwrap_or_default();
128 let line = line.split_whitespace().collect::<Vec<_>>().join(" ");
129 if line.chars().count() <= EXCERPT_CHARS {
130 return line;
131 }
132 let cut: String = line.chars().take(EXCERPT_CHARS - 1).collect();
133 format!("{}…", cut.trim_end())
134}
135
136fn principal(id: &str, username: &str) -> Principal {
137 Principal {
138 id: id.to_owned(),
139 username: username.to_lowercase(),
140 }
141}
142
143fn of_issue(issue: Issue) -> InboxSubject {
144 let body = issue.body.clone();
145 InboxSubject {
146 kind: Some(SubjectKind::Issue),
147 title: issue.title,
148 author: principal(&issue.author.id, &issue.author.username),
149 requested_by: issue.requested_by.map(|user| principal(&user.id, &user.username)),
150 assignees: issue.assignees,
151 mentions: people_mentioned(&body),
152 ..InboxSubject::default()
153 }
154}
155
156fn of_pull(pull: Pull, issue: Option<Issue>) -> InboxSubject {
157 InboxSubject {
158 kind: Some(SubjectKind::Pull),
159 title: pull.title,
160 author: principal(&pull.author.id, &pull.author.username),
161 requested_by: pull.requested_by.map(|user| principal(&user.id, &user.username)),
162 assignees: pull.assignees,
163 reviewers: pull.reviewers,
164 mentions: people_mentioned(pull.body.as_deref().unwrap_or_default()),
165 team_mentions: Vec::new(),
166 issue: issue.map(|issue| Box::new(of_issue(issue))),
167 comment: None,
168 }
169}
170
171fn of_comment(comment: Comment) -> InboxComment {
172 let event = comment.kind == CommentKind::Event;
173 InboxComment {
174 author: principal(&comment.author.id, &comment.author.username),
175 excerpt: excerpt(&comment.body),
176 mentions: if event { Vec::new() } else { people_mentioned(&comment.body) },
177 team_mentions: Vec::new(),
178 verdict: comment.verdict.map(|verdict| verdict.as_str().to_owned()),
179 event,
180 }
181}
182
183impl Work {
184 /// The issue or pull request numbered `number`, with its people, and
185 /// the comment asked about. None when there is no such issue or pull
186 /// request.
187 pub(crate) async fn inbox_subject(&self, a: InboxSubjectArgs) -> Result<Option<InboxSubject>> {
188 let comment = async {
189 let Some(id) = &a.comment_id else {
190 return Ok::<_, worker::Error>(None);
191 };
192 let Some(row) = self
193 .db
194 .prepare("SELECT * FROM comments WHERE id = ? AND repo_id = ?")
195 .bind(&[id.as_str().into(), a.repo_id.as_str().into()])?
196 .first::<CommentRow>(None)
197 .await?
198 else {
199 return Ok(None);
200 };
201 let comment = Comment::from(row);
202 let teams = if comment.kind == CommentKind::Event { Vec::new() } else { teams_mentioned(&comment.body) };
203 let mut shown = of_comment(comment.clone());
204 shown.team_mentions = self.team_mentions(&a.repo_id, &teams, &comment.author.id, &comment.author.username).await?;
205 Ok(Some(shown))
206 };
207 let (pull, comment) = futures_util::future::try_join(self.pull(&a.repo_id, a.number), comment).await?;
208 let mut subject = match pull {
209 Some(pull) => {
210 let issue = match pull.issue {
211 Some(number) => self.issue(&a.repo_id, number).await?,
212 None => None,
213 };
214 of_pull(pull, issue)
215 }
216 None => match self.issue(&a.repo_id, a.number).await? {
217 Some(issue) => of_issue(issue),
218 None => return Ok(None),
219 },
220 };
221 subject.comment = comment;
222 // A description's team mentions, for when it is opened.
223 if a.comment_id.is_none() {
224 let body = match subject.kind {
225 Some(SubjectKind::Pull) => self.pull(&a.repo_id, a.number).await?.and_then(|pull| pull.body),
226 _ => self.issue(&a.repo_id, a.number).await?.map(|issue| issue.body),
227 };
228 let teams = teams_mentioned(body.as_deref().unwrap_or_default());
229 let author = subject.author.clone();
230 subject.team_mentions = self.team_mentions(&a.repo_id, &teams, &author.id, &author.username).await?;
231 }
232 Ok(Some(subject))
233 }
234
235 /// The teams named, resolved, with who each mention tells.
236 async fn team_mentions(&self, repo_id: &str, teams: &[String], writer_id: &str, writer: &str) -> Result<Vec<TeamMentioned>> {
237 if teams.is_empty() {
238 return Ok(Vec::new());
239 }
240 let resolved: Vec<g1t_contracts::teams::ResolvedTeam> = g1t_kit::call(
241 &self.identity,
242 "resolve_teams",
243 &g1t_contracts::teams::ResolveTeamsArgs {
244 teams: teams.to_vec(),
245 repo_id: Some(repo_id.to_owned()),
246 asker: Some(writer_id.to_owned()),
247 },
248 )
249 .await?;
250 Ok(told_of(&resolved, writer))
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn people_are_mentioned_by_name_in_their_own_words() {
260 assert_eq!(people_mentioned("@ana can you look? cc @Bob-1."), vec!["ana", "bob-1"]);
261 // Once each, never g1t, never in code or a quote.
262 assert_eq!(people_mentioned("@ana @ana @g1t `@carl`\n> @dee said"), vec!["ana"]);
263 // Not an address, a package, a path or a domain.
264 assert!(people_mentioned("ops@ana.dev, @scope/pkg, a/@b, @ana.dev").is_empty());
265 assert_eq!(people_mentioned("(@ana)"), vec!["ana"]);
266 }
267
268 #[test]
269 fn teams_are_mentioned_as_workspace_slash_team() {
270 assert_eq!(teams_mentioned("cc @acme/backend and @Acme/Web-UI."), vec!["acme/backend", "acme/web-ui"]);
271 // Once each; never in code or a quote; not a person.
272 assert_eq!(teams_mentioned("@acme/backend @acme/backend `@acme/ops`\n> @acme/sre\n@ana"), vec!["acme/backend"]);
273 // Not an address, a deeper path or a versioned package.
274 assert!(teams_mentioned("ops@acme/backend a/@acme/x @acme/backend/src npm i @acme/backend@1").is_empty());
275 // And a person is still a person.
276 assert_eq!(people_mentioned("@ana and @acme/backend"), vec!["ana"]);
277 }
278
279 #[test]
280 fn a_team_mention_tells_its_people_when_it_may() {
281 use g1t_contracts::teams::{ResolvedTeam, TeamPerson};
282 let person = |name: &str| TeamPerson {
283 id: format!("usr_{name}"),
284 username: name.to_owned(),
285 };
286 let team = |notify: bool, sees: bool| ResolvedTeam {
287 workspace: "acme".into(),
288 slug: "backend".into(),
289 notify,
290 asker_sees: sees,
291 members: vec![person("ana"), person("bo")],
292 child_members: vec![person("cy")],
293 ..ResolvedTeam::default()
294 };
295 let told = told_of(&[team(true, true)], "bo");
296 assert_eq!(told[0].team, "acme/backend");
297 assert_eq!(told[0].members, vec!["ana", "cy"]);
298 assert!(told_of(&[team(false, true)], "zed")[0].members.is_empty());
299 assert!(told_of(&[team(true, false)], "zed")[0].members.is_empty());
300 }
301
302 #[test]
303 fn an_excerpt_is_the_first_line_said() {
304 assert_eq!(excerpt("\n\n```\ncode\n```"), "code");
305 assert_eq!(excerpt("> quoted\n Looks good \nmore"), "Looks good");
306 let long = "word ".repeat(60);
307 let cut = excerpt(&long);
308 assert!(cut.ends_with('…'));
309 assert_eq!(cut.chars().count(), EXCERPT_CHARS);
310 }
311}