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; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 9 | use g1t_contracts::inbox::{InboxComment, InboxSubject, InboxSubjectArgs, SubjectKind, TeamMentioned}; |
| Inbox: the events service tells people what needs them as events arrive | 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 | ||
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 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 | ||
| Inbox: the events service tells people what needs them as events arrive | 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 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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 | ||
| Inbox: the events service tells people what needs them as events arrive | 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 { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 221 | let body = issue.body.clone(); |
| Inbox: the events service tells people what needs them as events arrive | 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, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 228 | mentions: people_mentioned(&body), |
| Inbox: the events service tells people what needs them as events arrive | 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, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 241 | mentions: people_mentioned(pull.body.as_deref().unwrap_or_default()), |
| 242 | team_mentions: Vec::new(), | |
| Inbox: the events service tells people what needs them as events arrive | 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) }, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 254 | team_mentions: Vec::new(), |
| Inbox: the events service tells people what needs them as events arrive | 255 | verdict: comment.verdict.map(|verdict| verdict.as_str().to_owned()), |
| 256 | event, | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 257 | acting_for: comment.acting_for.as_ref().map(|user| principal(&user.id, &user.username)), |
| 258 | advisory: comment.advisory, | |
| 259 | agent: comment.agent, | |
| Inbox: the events service tells people what needs them as events arrive | 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 | }; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 272 | let Some(row) = self |
| Inbox: the events service tells people what needs them as events arrive | 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? | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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)) | |
| Inbox: the events service tells people what needs them as events arrive | 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; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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 | } | |
| Inbox: the events service tells people what needs them as events arrive | 312 | Ok(Some(subject)) |
| 313 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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 | } | |
| Inbox: the events service tells people what needs them as events arrive | 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] | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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] | |
| Inbox: the events service tells people what needs them as events arrive | 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 | } |