| 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, TeamMentioned}; |
| 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 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`. |
| 64 | pub(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. |
| 104 | pub(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. |
| 122 | pub(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 | |
| 136 | fn principal(id: &str, username: &str) -> Principal { |
| 137 | Principal { |
| 138 | id: id.to_owned(), |
| 139 | username: username.to_lowercase(), |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | fn 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 | |
| 156 | fn 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 | |
| 171 | fn 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 | |
| 183 | impl 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)] |
| 255 | mod 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 | } |