| 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::rows::CommentRow; |
| 16 | |
| 17 | /// The most people one comment notifies by name. |
| 18 | const MAX_MENTIONS: usize = 20; |
| 19 | /// About one line of a comment, shown under its title. |
| 20 | const EXCERPT_CHARS: usize = 140; |
| 21 | |
| 22 | /// What a comment says in its own words: fenced code blocks, code spans |
| 23 | /// and quoted lines are left out, so that quoting or showing a mention |
| 24 | /// does not make one. |
| 25 | pub(crate) fn spoken(body: &str) -> String { |
| 26 | let mut out = String::new(); |
| 27 | // The fence that opened the code block being skipped, if one is. |
| 28 | let mut fence: Option<String> = None; |
| 29 | for line in body.lines() { |
| 30 | let trimmed = line.trim_start(); |
| 31 | if let Some(marker) = &fence { |
| 32 | if trimmed.starts_with(marker.as_str()) { |
| 33 | fence = None; |
| 34 | } |
| 35 | out.push('\n'); |
| 36 | continue; |
| 37 | } |
| 38 | if let Some(first) = trimmed.chars().next() |
| 39 | && (first == '`' || first == '~') |
| 40 | { |
| 41 | let run = trimmed.chars().take_while(|c| *c == first).count(); |
| 42 | if run >= 3 { |
| 43 | fence = Some(std::iter::repeat_n(first, run).collect()); |
| 44 | out.push('\n'); |
| 45 | continue; |
| 46 | } |
| 47 | } |
| 48 | if trimmed.starts_with('>') { |
| 49 | out.push('\n'); |
| 50 | continue; |
| 51 | } |
| 52 | out.push_str(&without_code_spans(line)); |
| 53 | out.push('\n'); |
| 54 | } |
| 55 | out |
| 56 | } |
| 57 | |
| 58 | /// A line with its code spans blanked: a run of backticks opens one, and |
| 59 | /// the next run of the same length closes it. An unclosed run is text. |
| 60 | fn without_code_spans(line: &str) -> String { |
| 61 | let chars: Vec<char> = line.chars().collect(); |
| 62 | let run_at = |at: usize| chars[at..].iter().take_while(|c| **c == '`').count(); |
| 63 | let mut out = String::new(); |
| 64 | let mut i = 0; |
| 65 | while i < chars.len() { |
| 66 | if chars[i] != '`' { |
| 67 | out.push(chars[i]); |
| 68 | i += 1; |
| 69 | continue; |
| 70 | } |
| 71 | let open = run_at(i); |
| 72 | let mut j = i + open; |
| 73 | let mut closed = None; |
| 74 | while j < chars.len() { |
| 75 | if chars[j] == '`' { |
| 76 | let run = run_at(j); |
| 77 | if run == open { |
| 78 | closed = Some(j + run); |
| 79 | break; |
| 80 | } |
| 81 | j += run; |
| 82 | } else { |
| 83 | j += 1; |
| 84 | } |
| 85 | } |
| 86 | match closed { |
| 87 | Some(end) => { |
| 88 | out.push(' '); |
| 89 | i = end; |
| 90 | } |
| 91 | None => { |
| 92 | out.extend(std::iter::repeat_n('`', open)); |
| 93 | i += open; |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | out |
| 98 | } |
| 99 | |
| 100 | /// The people a comment mentions by name in its own words (not in code or |
| 101 | /// a quote), lowercased, each once. Not an email address, a package scope |
| 102 | /// or a path, and never a reserved name such as `g1t`. |
| 103 | pub(crate) fn people_mentioned(body: &str) -> Vec<String> { |
| 104 | let text = spoken(body); |
| 105 | let chars: Vec<char> = text.chars().collect(); |
| 106 | let mut people: Vec<String> = Vec::new(); |
| 107 | let mut at = 0; |
| 108 | while at < chars.len() && people.len() < MAX_MENTIONS { |
| 109 | if chars[at] != '@' { |
| 110 | at += 1; |
| 111 | continue; |
| 112 | } |
| 113 | let starts_clean = at == 0 || { |
| 114 | let before = chars[at - 1]; |
| 115 | !(before.is_alphanumeric() || "._%+-/\\@`=".contains(before)) |
| 116 | }; |
| 117 | let name: String = chars[at + 1..] |
| 118 | .iter() |
| 119 | .take_while(|c| c.is_ascii_alphanumeric() || **c == '-') |
| 120 | .collect(); |
| 121 | let end = at + 1 + name.chars().count(); |
| 122 | // A path or a package such as `@scope/name`, or a domain. |
| 123 | let ends_clean = match chars.get(end) { |
| 124 | None => true, |
| 125 | Some(c) if "_@/\\".contains(*c) => false, |
| 126 | Some('.') => chars.get(end + 1).is_none_or(|c| !c.is_alphanumeric()), |
| 127 | Some(_) => true, |
| 128 | }; |
| 129 | let name = name.to_lowercase(); |
| 130 | if starts_clean && ends_clean && is_valid_namespace(&name) && !people.contains(&name) { |
| 131 | people.push(name); |
| 132 | } |
| 133 | at = end.max(at + 1); |
| 134 | } |
| 135 | people |
| 136 | } |
| 137 | |
| 138 | /// The teams a comment mentions in its own words, as `workspace/slug`, |
| 139 | /// each once: `@acme/backend`, not in code, a quote, an address or a path |
| 140 | /// such as `@acme/backend/x` or `npm i @scope/pkg@1`. |
| 141 | pub(crate) fn teams_mentioned(body: &str) -> Vec<String> { |
| 142 | let text = spoken(body); |
| 143 | let chars: Vec<char> = text.chars().collect(); |
| 144 | let mut teams: Vec<String> = Vec::new(); |
| 145 | let mut at = 0; |
| 146 | while at < chars.len() && teams.len() < MAX_MENTIONS { |
| 147 | if chars[at] != '@' { |
| 148 | at += 1; |
| 149 | continue; |
| 150 | } |
| 151 | let starts_clean = at == 0 || { |
| 152 | let before = chars[at - 1]; |
| 153 | !(before.is_alphanumeric() || "._%+-/\\@`=".contains(before)) |
| 154 | }; |
| 155 | let name: String = chars[at + 1..] |
| 156 | .iter() |
| 157 | .take_while(|c| c.is_ascii_alphanumeric() || **c == '-' || **c == '/') |
| 158 | .collect(); |
| 159 | let end = at + 1 + name.chars().count(); |
| 160 | let ends_clean = match chars.get(end) { |
| 161 | None => true, |
| 162 | Some(c) if "_@\\".contains(*c) => false, |
| 163 | Some('.') => chars.get(end + 1).is_none_or(|c| !c.is_alphanumeric()), |
| 164 | Some(_) => true, |
| 165 | }; |
| 166 | if starts_clean |
| 167 | && ends_clean |
| 168 | && name.matches('/').count() == 1 |
| 169 | && let Some(team) = crate::team_reviews::team_name(&name) |
| 170 | && !teams.contains(&team) |
| 171 | { |
| 172 | teams.push(team); |
| 173 | } |
| 174 | at = end.max(at + 1); |
| 175 | } |
| 176 | teams |
| 177 | } |
| 178 | |
| 179 | /// Who each team mentioned tells: everyone in it and its child teams, when |
| 180 | /// the writer may see the team and it has notifications on. |
| 181 | pub(crate) fn told_of(teams: &[g1t_contracts::teams::ResolvedTeam], writer: &str) -> Vec<TeamMentioned> { |
| 182 | teams |
| 183 | .iter() |
| 184 | .map(|team| TeamMentioned { |
| 185 | team: format!("{}/{}", team.workspace, team.slug), |
| 186 | members: if team.notify && team.asker_sees { |
| 187 | team.everyone() |
| 188 | .map(|person| person.username.clone()) |
| 189 | .filter(|name| !name.eq_ignore_ascii_case(writer)) |
| 190 | .collect() |
| 191 | } else { |
| 192 | Vec::new() |
| 193 | }, |
| 194 | }) |
| 195 | .collect() |
| 196 | } |
| 197 | |
| 198 | /// The first line a comment says something on, cut to about a line. |
| 199 | pub(crate) fn excerpt(body: &str) -> String { |
| 200 | let line = body |
| 201 | .lines() |
| 202 | .map(str::trim) |
| 203 | .find(|line| !line.is_empty() && !line.starts_with("```") && !line.starts_with('>')) |
| 204 | .unwrap_or_default(); |
| 205 | let line = line.split_whitespace().collect::<Vec<_>>().join(" "); |
| 206 | if line.chars().count() <= EXCERPT_CHARS { |
| 207 | return line; |
| 208 | } |
| 209 | let cut: String = line.chars().take(EXCERPT_CHARS - 1).collect(); |
| 210 | format!("{}…", cut.trim_end()) |
| 211 | } |
| 212 | |
| 213 | fn principal(id: &str, username: &str) -> Principal { |
| 214 | Principal { |
| 215 | id: id.to_owned(), |
| 216 | username: username.to_lowercase(), |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | fn of_issue(issue: Issue) -> InboxSubject { |
| 221 | let body = issue.body.clone(); |
| 222 | InboxSubject { |
| 223 | kind: Some(SubjectKind::Issue), |
| 224 | title: issue.title, |
| 225 | author: principal(&issue.author.id, &issue.author.username), |
| 226 | requested_by: issue.requested_by.map(|user| principal(&user.id, &user.username)), |
| 227 | assignees: issue.assignees, |
| 228 | mentions: people_mentioned(&body), |
| 229 | ..InboxSubject::default() |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | fn of_pull(pull: Pull, issue: Option<Issue>) -> InboxSubject { |
| 234 | InboxSubject { |
| 235 | kind: Some(SubjectKind::Pull), |
| 236 | title: pull.title, |
| 237 | author: principal(&pull.author.id, &pull.author.username), |
| 238 | requested_by: pull.requested_by.map(|user| principal(&user.id, &user.username)), |
| 239 | assignees: pull.assignees, |
| 240 | reviewers: pull.reviewers, |
| 241 | mentions: people_mentioned(pull.body.as_deref().unwrap_or_default()), |
| 242 | team_mentions: Vec::new(), |
| 243 | issue: issue.map(|issue| Box::new(of_issue(issue))), |
| 244 | comment: None, |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | fn of_comment(comment: Comment) -> InboxComment { |
| 249 | let event = comment.kind == CommentKind::Event; |
| 250 | InboxComment { |
| 251 | author: principal(&comment.author.id, &comment.author.username), |
| 252 | excerpt: excerpt(&comment.body), |
| 253 | mentions: if event { Vec::new() } else { people_mentioned(&comment.body) }, |
| 254 | team_mentions: Vec::new(), |
| 255 | verdict: comment.verdict.map(|verdict| verdict.as_str().to_owned()), |
| 256 | event, |
| 257 | acting_for: comment.acting_for.as_ref().map(|user| principal(&user.id, &user.username)), |
| 258 | advisory: comment.advisory, |
| 259 | agent: comment.agent, |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | impl Work { |
| 264 | /// The issue or pull request numbered `number`, with its people, and |
| 265 | /// the comment asked about. None when there is no such issue or pull |
| 266 | /// request. |
| 267 | pub(crate) async fn inbox_subject(&self, a: InboxSubjectArgs) -> Result<Option<InboxSubject>> { |
| 268 | let comment = async { |
| 269 | let Some(id) = &a.comment_id else { |
| 270 | return Ok::<_, worker::Error>(None); |
| 271 | }; |
| 272 | let Some(row) = self |
| 273 | .db |
| 274 | .prepare("SELECT * FROM comments WHERE id = ? AND repo_id = ?") |
| 275 | .bind(&[id.as_str().into(), a.repo_id.as_str().into()])? |
| 276 | .first::<CommentRow>(None) |
| 277 | .await? |
| 278 | else { |
| 279 | return Ok(None); |
| 280 | }; |
| 281 | let comment = Comment::from(row); |
| 282 | let teams = if comment.kind == CommentKind::Event { Vec::new() } else { teams_mentioned(&comment.body) }; |
| 283 | let mut shown = of_comment(comment.clone()); |
| 284 | shown.team_mentions = self.team_mentions(&a.repo_id, &teams, &comment.author.id, &comment.author.username).await?; |
| 285 | Ok(Some(shown)) |
| 286 | }; |
| 287 | let (pull, comment) = futures_util::future::try_join(self.pull(&a.repo_id, a.number), comment).await?; |
| 288 | let mut subject = match pull { |
| 289 | Some(pull) => { |
| 290 | let issue = match pull.issue { |
| 291 | Some(number) => self.issue(&a.repo_id, number).await?, |
| 292 | None => None, |
| 293 | }; |
| 294 | of_pull(pull, issue) |
| 295 | } |
| 296 | None => match self.issue(&a.repo_id, a.number).await? { |
| 297 | Some(issue) => of_issue(issue), |
| 298 | None => return Ok(None), |
| 299 | }, |
| 300 | }; |
| 301 | subject.comment = comment; |
| 302 | // A description's team mentions, for when it is opened. |
| 303 | if a.comment_id.is_none() { |
| 304 | let body = match subject.kind { |
| 305 | Some(SubjectKind::Pull) => self.pull(&a.repo_id, a.number).await?.and_then(|pull| pull.body), |
| 306 | _ => self.issue(&a.repo_id, a.number).await?.map(|issue| issue.body), |
| 307 | }; |
| 308 | let teams = teams_mentioned(body.as_deref().unwrap_or_default()); |
| 309 | let author = subject.author.clone(); |
| 310 | subject.team_mentions = self.team_mentions(&a.repo_id, &teams, &author.id, &author.username).await?; |
| 311 | } |
| 312 | Ok(Some(subject)) |
| 313 | } |
| 314 | |
| 315 | /// The teams named, resolved, with who each mention tells. |
| 316 | async fn team_mentions(&self, repo_id: &str, teams: &[String], writer_id: &str, writer: &str) -> Result<Vec<TeamMentioned>> { |
| 317 | if teams.is_empty() { |
| 318 | return Ok(Vec::new()); |
| 319 | } |
| 320 | let resolved: Vec<g1t_contracts::teams::ResolvedTeam> = g1t_kit::call( |
| 321 | &self.identity, |
| 322 | "resolve_teams", |
| 323 | &g1t_contracts::teams::ResolveTeamsArgs { |
| 324 | teams: teams.to_vec(), |
| 325 | repo_id: Some(repo_id.to_owned()), |
| 326 | asker: Some(writer_id.to_owned()), |
| 327 | }, |
| 328 | ) |
| 329 | .await?; |
| 330 | Ok(told_of(&resolved, writer)) |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | #[cfg(test)] |
| 335 | mod tests { |
| 336 | use super::*; |
| 337 | |
| 338 | #[test] |
| 339 | fn people_are_mentioned_by_name_in_their_own_words() { |
| 340 | assert_eq!(people_mentioned("@ana can you look? cc @Bob-1."), vec!["ana", "bob-1"]); |
| 341 | // Once each, never g1t, never in code or a quote. |
| 342 | assert_eq!(people_mentioned("@ana @ana @g1t `@carl`\n> @dee said"), vec!["ana"]); |
| 343 | // Not an address, a package, a path or a domain. |
| 344 | assert!(people_mentioned("ops@ana.dev, @scope/pkg, a/@b, @ana.dev").is_empty()); |
| 345 | assert_eq!(people_mentioned("(@ana)"), vec!["ana"]); |
| 346 | } |
| 347 | |
| 348 | #[test] |
| 349 | fn teams_are_mentioned_as_workspace_slash_team() { |
| 350 | assert_eq!(teams_mentioned("cc @acme/backend and @Acme/Web-UI."), vec!["acme/backend", "acme/web-ui"]); |
| 351 | // Once each; never in code or a quote; not a person. |
| 352 | assert_eq!(teams_mentioned("@acme/backend @acme/backend `@acme/ops`\n> @acme/sre\n@ana"), vec!["acme/backend"]); |
| 353 | // Not an address, a deeper path or a versioned package. |
| 354 | assert!(teams_mentioned("ops@acme/backend a/@acme/x @acme/backend/src npm i @acme/backend@1").is_empty()); |
| 355 | // And a person is still a person. |
| 356 | assert_eq!(people_mentioned("@ana and @acme/backend"), vec!["ana"]); |
| 357 | } |
| 358 | |
| 359 | #[test] |
| 360 | fn a_team_mention_tells_its_people_when_it_may() { |
| 361 | use g1t_contracts::teams::{ResolvedTeam, TeamPerson}; |
| 362 | let person = |name: &str| TeamPerson { |
| 363 | id: format!("usr_{name}"), |
| 364 | username: name.to_owned(), |
| 365 | }; |
| 366 | let team = |notify: bool, sees: bool| ResolvedTeam { |
| 367 | workspace: "acme".into(), |
| 368 | slug: "backend".into(), |
| 369 | notify, |
| 370 | asker_sees: sees, |
| 371 | members: vec![person("ana"), person("bo")], |
| 372 | child_members: vec![person("cy")], |
| 373 | ..ResolvedTeam::default() |
| 374 | }; |
| 375 | let told = told_of(&[team(true, true)], "bo"); |
| 376 | assert_eq!(told[0].team, "acme/backend"); |
| 377 | assert_eq!(told[0].members, vec!["ana", "cy"]); |
| 378 | assert!(told_of(&[team(false, true)], "zed")[0].members.is_empty()); |
| 379 | assert!(told_of(&[team(true, false)], "zed")[0].members.is_empty()); |
| 380 | } |
| 381 | |
| 382 | #[test] |
| 383 | fn an_excerpt_is_the_first_line_said() { |
| 384 | assert_eq!(excerpt("\n\n```\ncode\n```"), "code"); |
| 385 | assert_eq!(excerpt("> quoted\n Looks good \nmore"), "Looks good"); |
| 386 | let long = "word ".repeat(60); |
| 387 | let cut = excerpt(&long); |
| 388 | assert!(cut.ends_with('…')); |
| 389 | assert_eq!(cut.chars().count(), EXCERPT_CHARS); |
| 390 | } |
| 391 | } |