Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
391 linesCodeBlameRaw
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::rows::CommentRow;
16
17/// The most people one comment notifies by name.
18const MAX_MENTIONS: usize = 20;
19/// About one line of a comment, shown under its title.
20const 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.
25pub(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.
60fn 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`.
103pub(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`.
141pub(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.
181pub(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.
199pub(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
213fn principal(id: &str, username: &str) -> Principal {
214 Principal {
215 id: id.to_owned(),
216 username: username.to_lowercase(),
217 }
218}
219
220fn 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
233fn 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
248fn 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
263impl 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)]
335mod 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}