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
550 linesCodeBlameRaw
1//! Comments on commits (migrations/0043_commit_comments.sql): on the whole
2//! commit, or on one line of a file it changed, as the commit left it.
3//!
4//! Anyone who can read the repository reads them, and comments unless the
5//! repository turned commit comments off (`allow_commit_comments`); those
6//! already written stay readable either way. Only its author edits one;
7//! its author or a maintainer (`ManageSettings`, as for issue comments)
8//! deletes it. Each change is published as `commit_comment.created`,
9//! `.edited` or `.deleted` with the comment, and the inbox tells the
10//! commit's author (when their address is a g1t account's) and whoever it
11//! mentions (events/src/inbox.rs).
12
13use std::collections::HashMap;
14
15use g1t_contracts::access::{self, Capability};
16use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs};
17use g1t_contracts::events::{ChangedFrom, CommentChanges, CommitCommentEvent};
18use g1t_contracts::repos::{ChangedFilesArgs, Commit, FileList, LogArgs, MAX_LISTED_FILES, Repo, RepoPath};
19use g1t_contracts::time::rfc3339;
20use g1t_contracts::work::{AddCommitCommentArgs, CommitComment, CommitCommentArgs, CommitCommentsArgs};
21use g1t_contracts::{FailureCode, Outcome, User, new_id};
22use g1t_kit::now_ms;
23use serde::Deserialize;
24use worker::wasm_bindgen::JsValue;
25use worker::Result;
26
27use crate::Work;
28use crate::inbox::{excerpt, people_mentioned, teams_mentioned};
29use crate::retired::writable;
30
31/// The longest a comment may be, in characters.
32pub(crate) const MAX_BODY_CHARS: usize = 65_536;
33/// A repository's comments are listed this many at a time.
34const PAGE: u32 = 30;
35/// A commit's comments: at most this many are listed.
36const MAX_ON_COMMIT: u32 = 1_000;
37const MAX_PATH_CHARS: usize = 4_096;
38
39/// `repo.purged`: a repository's commit comments go with it (retired.rs).
40pub(crate) const PURGED: &str = "DELETE FROM commit_comments WHERE repo_id = ?1";
41
42/// Unwraps an `Outcome`, returning its failure from the enclosing method.
43macro_rules! check {
44 ($outcome:expr) => {
45 match $outcome {
46 Outcome::Ok(value) => value,
47 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
48 }
49 };
50}
51
52#[derive(Deserialize)]
53struct Row {
54 id: String,
55 repo_id: String,
56 commit_hash: String,
57 path: Option<String>,
58 line: Option<u32>,
59 author_id: String,
60 author_name: String,
61 body: String,
62 created_at: String,
63 updated_at: Option<String>,
64}
65
66impl From<Row> for CommitComment {
67 fn from(row: Row) -> Self {
68 CommitComment {
69 id: row.id,
70 repo_id: row.repo_id,
71 commit: row.commit_hash,
72 path: row.path,
73 line: row.line,
74 author: row.author_name,
75 body: row.body,
76 created_at: row.created_at,
77 updated_at: row.updated_at,
78 }
79 }
80}
81
82/// A trimmed body, or why it cannot be used.
83pub(crate) fn valid_body(body: &str) -> std::result::Result<&str, &'static str> {
84 let body = body.trim();
85 if body.is_empty() {
86 Err("A comment cannot be empty.")
87 } else if body.chars().count() > MAX_BODY_CHARS {
88 Err("That comment is too long.")
89 } else {
90 Ok(body)
91 }
92}
93
94/// A commit named by its hash, whole or its start (at least 4 hex digits),
95/// lowercased; never a branch or tag name.
96pub(crate) fn valid_commit(commit: &str) -> Option<String> {
97 let commit = commit.trim().to_ascii_lowercase();
98 ((4..=64).contains(&commit.len()) && commit.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(commit)
99}
100
101/// Where on the commit a comment is: nowhere in particular, or one line
102/// (from 1) of one file. A path and a line come together.
103pub(crate) fn valid_place(path: Option<&str>, line: Option<u32>) -> std::result::Result<Option<(String, u32)>, &'static str> {
104 let path = path.map(str::trim).filter(|path| !path.is_empty());
105 match (path, line) {
106 (None, None) => Ok(None),
107 (None, Some(_)) => Err("Say which file the line is in."),
108 (Some(_), None) => Err("Say which line of the file the comment is on."),
109 (Some(_), Some(0)) => Err("Lines are numbered from 1."),
110 (Some(path), Some(line)) => {
111 let path = path.trim_start_matches('/');
112 if path.is_empty()
113 || path.chars().count() > MAX_PATH_CHARS
114 || path.contains('\0')
115 || path.split('/').any(|part| part.is_empty() || part == "." || part == "..")
116 {
117 return Err("That is not a file's path.");
118 }
119 Ok(Some((path.to_owned(), line)))
120 }
121 }
122}
123
124/// Whether `actor` may change a comment written by `author_id`: edit it
125/// only as its author; delete it as its author or a maintainer.
126pub(crate) fn may_change(author_id: &str, actor_id: &str, maintains: bool, deleting: bool) -> std::result::Result<(), &'static str> {
127 if author_id == actor_id {
128 Ok(())
129 } else if deleting && maintains {
130 Ok(())
131 } else if deleting {
132 Err("Only its author or a maintainer can delete this comment.")
133 } else {
134 Err("Only its author can edit this comment.")
135 }
136}
137
138/// Whether a commit's changed files (`list`) include `path` as the commit
139/// left it. A list cut short cannot say a file is missing.
140pub(crate) fn changes_file(list: &FileList, path: &str) -> bool {
141 list.truncated || list.files.iter().any(|file| file.path == path && file.hash.is_some())
142}
143
144/// Where a commit is on g1t.sh.
145fn link(repo: &Repo, commit: &str) -> String {
146 format!("/{}/{}/commit/{commit}", repo.namespace, repo.name)
147}
148
149fn event(repo: &Repo, comment: &CommitComment) -> CommitCommentEvent {
150 CommitCommentEvent {
151 comment_id: comment.id.clone(),
152 repo_id: repo.id.clone(),
153 commit: comment.commit.clone(),
154 comment: comment.clone(),
155 changes: None,
156 link: link(repo, &comment.commit),
157 excerpt: None,
158 mentions: Vec::new(),
159 team_mentions: Vec::new(),
160 commit_author: None,
161 }
162}
163
164impl Work {
165 /// `commit_comments`: a commit's comments, oldest first; with no
166 /// commit, the repository's, newest first, a page at a time.
167 pub(crate) async fn commit_comments(&self, a: CommitCommentsArgs) -> Result<Outcome<Vec<CommitComment>>> {
168 let commit = match a.commit.as_deref().map(str::trim).filter(|commit| !commit.is_empty()) {
169 Some(commit) => match valid_commit(commit) {
170 Some(commit) => Some(commit),
171 None => return Ok(Outcome::fail(FailureCode::Invalid, "That is not a commit's hash.")),
172 },
173 None => None,
174 };
175 let repo = check!(self.repo(&a.repo, &a.viewer).await?);
176 let query = match &commit {
177 // A whole hash is matched as it is; the start of one, as a prefix.
178 Some(commit) if commit.len() == 40 || commit.len() == 64 => self
179 .db
180 .prepare(format!(
181 "SELECT * FROM commit_comments WHERE repo_id = ? AND commit_hash = ?
182 ORDER BY created_at, id LIMIT {MAX_ON_COMMIT}"
183 ))
184 .bind(&[repo.id.as_str().into(), commit.as_str().into()])?,
185 Some(commit) => self
186 .db
187 .prepare(format!(
188 "SELECT * FROM commit_comments WHERE repo_id = ? AND commit_hash LIKE ? || '%'
189 ORDER BY created_at, id LIMIT {MAX_ON_COMMIT}"
190 ))
191 .bind(&[repo.id.as_str().into(), commit.as_str().into()])?,
192 None => {
193 let offset = a.page.unwrap_or(1).max(1).saturating_sub(1).saturating_mul(PAGE);
194 self.db
195 .prepare(
196 "SELECT * FROM commit_comments WHERE repo_id = ?
197 ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?",
198 )
199 .bind(&[repo.id.as_str().into(), PAGE.into(), offset.into()])?
200 }
201 };
202 let rows = query.all().await?.results::<Row>()?;
203 Ok(Outcome::Ok(rows.into_iter().map(CommitComment::from).collect()))
204 }
205
206 /// `add_commit_comment`: comments on a commit of the repository, or on
207 /// one line of a file it changed.
208 pub(crate) async fn add_commit_comment(&self, a: AddCommitCommentArgs) -> Result<Outcome<CommitComment>> {
209 if !a.actor.verified {
210 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
211 }
212 let body = match valid_body(&a.body) {
213 Ok(body) => body.to_owned(),
214 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
215 };
216 let Some(asked) = valid_commit(&a.commit) else {
217 return Ok(Outcome::fail(FailureCode::Invalid, "That is not a commit's hash."));
218 };
219 let place = match valid_place(a.path.as_deref(), a.line) {
220 Ok(place) => place,
221 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
222 };
223 let viewer = Some(a.actor.clone());
224 let repo = check!(self.repo(&a.repo, &viewer).await?);
225 check!(writable(&repo));
226 if !self.settings(&repo.id).await?.allow_commit_comments {
227 return Ok(Outcome::fail(FailureCode::Forbidden, "Commit comments are turned off for this repository."));
228 }
229 let Some(commit) = self.commented_commit(&a.repo, &viewer, &asked).await? else {
230 return Ok(Outcome::fail(FailureCode::NotFound, "Commit not found."));
231 };
232 if let Some((path, _)) = &place {
233 let changed: FileList = g1t_kit::call(
234 &self.repos,
235 "changed_files",
236 &ChangedFilesArgs {
237 repo_id: repo.id.clone(),
238 base: commit.parents.first().cloned(),
239 head: commit.hash.clone(),
240 skip_dirs: Vec::new(),
241 limit: MAX_LISTED_FILES,
242 },
243 )
244 .await?;
245 if !changes_file(&changed, path) {
246 return Ok(Outcome::fail(FailureCode::Invalid, "That file is not one this commit changed."));
247 }
248 }
249
250 let now = now_ms();
251 let comment = CommitComment {
252 id: new_id("ccm", now),
253 repo_id: repo.id.clone(),
254 commit: commit.hash.clone(),
255 path: place.as_ref().map(|(path, _)| path.clone()),
256 line: place.as_ref().map(|(_, line)| *line),
257 author: a.actor.username.clone(),
258 body,
259 created_at: rfc3339(now),
260 updated_at: None,
261 };
262 self.db
263 .prepare(
264 "INSERT INTO commit_comments
265 (id, repo_id, commit_hash, path, line, author_id, author_name, body, created_at)
266 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
267 )
268 .bind(&[
269 comment.id.as_str().into(),
270 repo.id.as_str().into(),
271 comment.commit.as_str().into(),
272 comment.path.as_deref().map_or(JsValue::NULL, JsValue::from),
273 comment.line.map_or(JsValue::NULL, JsValue::from),
274 a.actor.id.as_str().into(),
275 a.actor.username.as_str().into(),
276 comment.body.as_str().into(),
277 comment.created_at.as_str().into(),
278 ])?
279 .run()
280 .await?;
281
282 // Who hears of it: whoever it mentions, and the commit's author.
283 let mut data = event(&repo, &comment);
284 data.excerpt = Some(excerpt(&comment.body));
285 data.mentions = people_mentioned(&comment.body);
286 let teams = teams_mentioned(&comment.body);
287 match self.team_mentions(&repo.id, &teams, &a.actor.id, &a.actor.username).await {
288 Ok(told) => data.team_mentions = told,
289 Err(error) => worker::console_error!("commit comment team mentions not resolved: {error}"),
290 }
291 data.commit_author = self.commit_author(&commit, &a.actor).await;
292 self.publish_quietly("commit_comment.created", &repo, &a.actor, data).await;
293 Ok(Outcome::Ok(comment))
294 }
295
296 /// `edit_commit_comment`: its author changes what it says.
297 pub(crate) async fn edit_commit_comment(&self, a: CommitCommentArgs) -> Result<Outcome<CommitComment>> {
298 let body = match valid_body(&a.body) {
299 Ok(body) => body.to_owned(),
300 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
301 };
302 let (repo, mut comment) = check!(self.changeable_commit_comment(&a, false).await?);
303 if comment.body == body {
304 return Ok(Outcome::Ok(comment));
305 }
306 let now = rfc3339(now_ms());
307 self.db
308 .prepare("UPDATE commit_comments SET body = ?, updated_at = ? WHERE id = ? AND repo_id = ?")
309 .bind(&[body.as_str().into(), now.as_str().into(), comment.id.as_str().into(), repo.id.as_str().into()])?
310 .run()
311 .await?;
312 let before = std::mem::replace(&mut comment.body, body);
313 comment.updated_at = Some(now);
314 let mut data = event(&repo, &comment);
315 data.changes = Some(CommentChanges { body: ChangedFrom { from: before } });
316 self.publish_quietly("commit_comment.edited", &repo, &a.actor, data).await;
317 Ok(Outcome::Ok(comment))
318 }
319
320 /// `delete_commit_comment`: its author or a maintainer deletes it.
321 pub(crate) async fn delete_commit_comment(&self, a: CommitCommentArgs) -> Result<Outcome<bool>> {
322 let (repo, comment) = check!(self.changeable_commit_comment(&a, true).await?);
323 self.db
324 .prepare("DELETE FROM commit_comments WHERE id = ? AND repo_id = ?")
325 .bind(&[comment.id.as_str().into(), repo.id.as_str().into()])?
326 .run()
327 .await?;
328 self.publish_quietly("commit_comment.deleted", &repo, &a.actor, event(&repo, &comment)).await;
329 Ok(Outcome::Ok(true))
330 }
331
332 /// A comment in the repository, when `actor` may edit it or, with
333 /// `deleting`, delete it (`may_change`). Not found for someone who
334 /// cannot read the repository.
335 async fn changeable_commit_comment(&self, a: &CommitCommentArgs, deleting: bool) -> Result<Outcome<(Repo, CommitComment)>> {
336 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
337 check!(writable(&repo));
338 let Some(row) = self
339 .db
340 .prepare("SELECT * FROM commit_comments WHERE id = ? AND repo_id = ?")
341 .bind(&[a.id.trim().into(), repo.id.as_str().into()])?
342 .first::<Row>(None)
343 .await?
344 else {
345 return Ok(Outcome::fail(FailureCode::NotFound, "Comment not found."));
346 };
347 let maintains = access::can(Some(&a.actor), &repo, Capability::ManageSettings);
348 if let Err(refusal) = may_change(&row.author_id, &a.actor.id, maintains, deleting) {
349 return Ok(Outcome::fail(FailureCode::Forbidden, refusal));
350 }
351 Ok(Outcome::Ok((repo, CommitComment::from(row))))
352 }
353
354 /// The commit `asked` names (its hash, or the start of it) in the
355 /// repository, as `viewer` may read it; none for a branch or tag that
356 /// happens to look like a hash.
357 async fn commented_commit(&self, repo: &RepoPath, viewer: &g1t_contracts::Viewer, asked: &str) -> Result<Option<Commit>> {
358 let found: Outcome<Vec<Commit>> = g1t_kit::call(
359 &self.repos,
360 "log",
361 &LogArgs {
362 path: repo.clone(),
363 viewer: viewer.clone(),
364 git_ref: Some(asked.to_owned()),
365 limit: 1,
366 },
367 )
368 .await?;
369 Ok(match found {
370 Outcome::Ok(commits) => commits.into_iter().next().filter(|commit| commit.hash.to_ascii_lowercase().starts_with(asked)),
371 Outcome::Fail(_) => None,
372 })
373 }
374
375 /// The commit's author's username, when their address is a g1t
376 /// account's and it is not `actor` commenting on their own commit.
377 /// Best effort: none when it cannot be told.
378 async fn commit_author(&self, commit: &Commit, actor: &User) -> Option<String> {
379 let email = commit.author.email.trim().to_lowercase();
380 if email.is_empty() {
381 return None;
382 }
383 let owners: HashMap<String, EmailOwner> =
384 match g1t_kit::call(&self.identity, "email_owners", &EmailOwnersArgs { emails: vec![email.clone()] }).await {
385 Ok(owners) => owners,
386 Err(error) => {
387 worker::console_error!("commit author not looked up: {error}");
388 return None;
389 }
390 };
391 owners.get(&email).filter(|owner| owner.id != actor.id).map(|owner| owner.username.to_lowercase())
392 }
393
394 /// Publishes a change to a comment that is already made: a failure is
395 /// logged, never undoes it.
396 async fn publish_quietly(&self, kind: &'static str, repo: &Repo, actor: &User, data: CommitCommentEvent) {
397 if let Err(error) = self.publish(kind, &repo.id, actor, data).await {
398 worker::console_error!("{kind} not published: {error}");
399 }
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use g1t_contracts::repos::FileEntry;
407
408 #[test]
409 fn a_body_says_something_and_not_too_much() {
410 assert_eq!(valid_body(" looks good \n"), Ok("looks good"));
411 assert!(valid_body(" \n\t").is_err());
412 assert!(valid_body(&"a".repeat(MAX_BODY_CHARS)).is_ok());
413 assert_eq!(valid_body(&"a".repeat(MAX_BODY_CHARS + 1)), Err("That comment is too long."));
414 // Counted in characters, not bytes.
415 assert!(valid_body(&"é".repeat(MAX_BODY_CHARS)).is_ok());
416 }
417
418 #[test]
419 fn a_commit_is_named_by_its_hash_never_a_branch() {
420 assert_eq!(valid_commit(" ABCDEF12 "), Some("abcdef12".into()));
421 assert_eq!(valid_commit(&"a".repeat(40)), Some("a".repeat(40)));
422 assert_eq!(valid_commit(&"b".repeat(64)), Some("b".repeat(64)));
423 assert_eq!(valid_commit("abc"), None);
424 assert_eq!(valid_commit(&"a".repeat(65)), None);
425 assert_eq!(valid_commit("main"), None);
426 assert_eq!(valid_commit("HEAD~1"), None);
427 }
428
429 #[test]
430 fn a_line_comment_names_a_file_and_a_line_from_one() {
431 assert_eq!(valid_place(None, None), Ok(None));
432 assert_eq!(valid_place(Some(" "), None), Ok(None));
433 assert_eq!(valid_place(Some("/src/lib.rs"), Some(3)), Ok(Some(("src/lib.rs".into(), 3))));
434 assert!(valid_place(Some("src/lib.rs"), Some(0)).is_err());
435 assert!(valid_place(Some("src/lib.rs"), None).is_err());
436 assert!(valid_place(None, Some(3)).is_err());
437 assert!(valid_place(Some("src/../etc"), Some(1)).is_err());
438 assert!(valid_place(Some("src//lib.rs"), Some(1)).is_err());
439 assert!(valid_place(Some("src/"), Some(1)).is_err());
440 }
441
442 #[test]
443 fn authors_edit_and_maintainers_only_delete() {
444 assert!(may_change("usr_1", "usr_1", false, false).is_ok());
445 assert!(may_change("usr_1", "usr_1", false, true).is_ok());
446 assert!(may_change("usr_1", "usr_2", true, true).is_ok());
447 assert_eq!(may_change("usr_1", "usr_2", true, false), Err("Only its author can edit this comment."));
448 assert!(may_change("usr_1", "usr_2", false, true).is_err());
449 assert!(may_change("usr_1", "usr_2", false, false).is_err());
450 }
451
452 #[test]
453 fn a_line_is_on_a_file_the_commit_changed_and_kept() {
454 let entry = |path: &str, hash: Option<&str>| FileEntry { path: path.into(), hash: hash.map(str::to_owned), kind: None };
455 let list = FileList {
456 commit: Some("c".into()),
457 date: None,
458 files: vec![entry("src/lib.rs", Some("h1")), entry("old.rs", None)],
459 truncated: false,
460 };
461 assert!(changes_file(&list, "src/lib.rs"));
462 assert!(!changes_file(&list, "src/main.rs"));
463 // A file the commit deleted has no lines left to comment on.
464 assert!(!changes_file(&list, "old.rs"));
465 assert!(changes_file(&FileList { truncated: true, ..list }, "src/main.rs"));
466 }
467
468 #[test]
469 fn events_link_to_the_commit_and_say_only_what_they_have() {
470 let comment = CommitComment {
471 id: "ccm_1".into(),
472 repo_id: "rep_1".into(),
473 commit: "abc123".into(),
474 path: None,
475 line: None,
476 author: "ana".into(),
477 body: "hi".into(),
478 created_at: "2026-10-14T00:00:00Z".into(),
479 updated_at: None,
480 };
481 let mut data = CommitCommentEvent {
482 comment_id: comment.id.clone(),
483 repo_id: "rep_1".into(),
484 commit: comment.commit.clone(),
485 comment,
486 changes: None,
487 link: "/acme/rocket/commit/abc123".into(),
488 excerpt: None,
489 mentions: Vec::new(),
490 team_mentions: Vec::new(),
491 commit_author: None,
492 };
493 let value = serde_json::to_value(&data).unwrap();
494 assert_eq!(value["commentId"], "ccm_1");
495 assert_eq!(value["comment"]["author"], "ana");
496 assert!(value.get("mentions").is_none() && value.get("commitAuthor").is_none() && value.get("changes").is_none());
497 data.mentions = vec!["bo".into()];
498 data.commit_author = Some("cy".into());
499 let value = serde_json::to_value(&data).unwrap();
500 assert_eq!(value["mentions"][0], "bo");
501 assert_eq!(value["commitAuthor"], "cy");
502 }
503
504 /// The table as the migrations leave it, with the statements this
505 /// module and a purge run against it.
506 #[test]
507 fn the_table_holds_comments_by_commit_and_goes_with_its_repository() {
508 let db = rusqlite::Connection::open_in_memory().unwrap();
509 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations");
510 let mut names: Vec<String> = std::fs::read_dir(dir)
511 .unwrap()
512 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
513 .filter(|name| name.ends_with(".sql"))
514 .collect();
515 names.sort();
516 for name in names {
517 db.execute_batch(&std::fs::read_to_string(format!("{dir}/{name}")).unwrap()).unwrap();
518 }
519 let insert = "INSERT INTO commit_comments
520 (id, repo_id, commit_hash, path, line, author_id, author_name, body, created_at)
521 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
522 for (id, repo, commit, at) in [
523 ("ccm_1", "rep_1", "aaaa1111", "2026-10-14T00:00:01Z"),
524 ("ccm_2", "rep_1", "aaaa1111", "2026-10-14T00:00:02Z"),
525 ("ccm_3", "rep_1", "bbbb2222", "2026-10-14T00:00:03Z"),
526 ("ccm_4", "rep_2", "aaaa1111", "2026-10-14T00:00:04Z"),
527 ] {
528 db.execute(insert, rusqlite::params![id, repo, commit, None::<String>, None::<u32>, "usr_1", "ana", "hi", at]).unwrap();
529 }
530 let ids = |sql: &str, params: &[&dyn rusqlite::ToSql]| -> Vec<String> {
531 let mut statement = db.prepare(sql).unwrap();
532 statement.query_map(params, |row| row.get(0)).unwrap().map(|id| id.unwrap()).collect()
533 };
534 assert_eq!(
535 ids("SELECT id FROM commit_comments WHERE repo_id = ? AND commit_hash LIKE ? || '%' ORDER BY created_at, id", &[&"rep_1", &"aaaa"]),
536 ["ccm_1", "ccm_2"]
537 );
538 assert_eq!(
539 ids("SELECT id FROM commit_comments WHERE repo_id = ? ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?", &[&"rep_1", &2, &0]),
540 ["ccm_3", "ccm_2"]
541 );
542 // An account purged: its comments stay, as ghost's (ghost.rs).
543 for sql in crate::ghost::statements().iter().filter(|sql| sql.contains("commit_comments")) {
544 db.execute(sql, rusqlite::params!["usr_1"]).unwrap();
545 }
546 assert_eq!(ids("SELECT DISTINCT author_name FROM commit_comments", &[]), ["ghost"]);
547 db.execute(PURGED, rusqlite::params!["rep_1"]).unwrap();
548 assert_eq!(ids("SELECT id FROM commit_comments ORDER BY id", &[]), ["ccm_4"]);
549 }
550}