//! Comments on a pull request, posted once per marker. use anyhow::{Context, Result}; use forgejo_api::structs::{ Comment, CreateIssueCommentOption, EditIssueCommentOption, IssueGetCommentsQuery, }; use super::Client; impl Client { /// Every comment on pull request `pr` of `owner/repo` written by this /// client's own forge login. A comment by anyone else can carry a marker /// (a quote-reply copies it) and is neither counted nor edited. async fn own_comments(&self, owner: &str, repo: &str, pr: u64) -> Result> { let me = self .api .call(|api| api.user_get_current().into_future()) .await .context("reading this client's own forge login")? .login .context("the forge returned no login for this client")?; Ok(self .comments(owner, repo, pr) .await? .into_iter() .filter(|c| c.user.as_ref().and_then(|u| u.login.as_deref()) == Some(me.as_str())) .collect()) } /// Every comment on pull request `pr` of `owner/repo`. async fn comments(&self, owner: &str, repo: &str, pr: u64) -> Result> { let index = i64::try_from(pr).context("PR number out of range")?; self.api .call(|api| { Box::pin( api.issue_get_comments( owner, repo, index, IssueGetCommentsQuery { since: None, before: None, }, ) .all(), ) }) .await .with_context(|| format!("listing the comments on {owner}/{repo}#{pr}")) } /// Comment `body` on pull request `pr` of `owner/repo` unless a comment /// there already contains `marker`. Whether a comment was posted. /// /// The marker is what the comment carries, so the check holds across a /// restart and across every caller. It reads the comments first, so two /// concurrent calls with one marker can both post. pub(crate) async fn comment_once( &self, owner: &str, repo: &str, pr: u64, marker: &str, body: &str, ) -> Result { let index = i64::try_from(pr).context("PR number out of range")?; let existing = self.own_comments(owner, repo, pr).await?; if existing .iter() .any(|c| c.body.as_deref().is_some_and(|b| b.contains(marker))) { return Ok(false); } let option = CreateIssueCommentOption { body: body.to_owned(), updated_at: None, }; self.api .call(|api| { api.issue_create_comment(owner, repo, index, option.clone()) .into_future() }) .await .with_context(|| format!("commenting on {owner}/{repo}#{pr}"))?; Ok(true) } /// Rewrite every comment of this client's own on pull request `pr` of /// `owner/repo` that contains `marker`: the marker becomes `cleared`, which /// lets [`Self::comment_once`] post that marker again, and `resolved` is /// appended on a line of its own. A comment the forge refuses to edit is /// logged and skipped, so one refusal leaves the rest cleared. How many /// comments were rewritten. /// /// # Errors /// /// The comments could not be listed. pub(crate) async fn clear_marker( &self, owner: &str, repo: &str, pr: u64, marker: &str, cleared: &str, resolved: &str, ) -> Result { let mut rewritten = 0; for comment in self.own_comments(owner, repo, pr).await? { let (Some(id), Some(body)) = (comment.id, comment.body.as_deref()) else { continue; }; if !body.contains(marker) { continue; } let option = EditIssueCommentOption { body: format!("{}\n\n{resolved}", body.replace(marker, cleared)), updated_at: None, }; let edited = self .api .call(|api| { api.issue_edit_comment(owner, repo, id, option.clone()) .into_future() }) .await; match edited { Ok(_) => rewritten += 1, Err(e) => tracing::warn!( id, %owner, %repo, pr, error = %format!("{e:#}"), "swarm forge: could not rewrite a resolved build comment" ), } } Ok(rewritten) } } #[cfg(test)] pub(crate) mod tests { use std::sync::{Arc, Mutex}; use axum::http::{Method, StatusCode, Uri}; use super::*; /// The comments a stand-in forge holds, as the issue path they were /// posted to and their body. pub(crate) type Posted = Arc>>; /// A comment whose body starts with this is written by someone other than /// the client's own login. pub(crate) const HUMAN: &str = "HUMAN:"; /// A comment whose body contains this is one the forge refuses to edit. pub(crate) const REFUSED: &str = "REFUSED"; /// A stand-in forge that stores the comments posted to any issue path and /// lists them back. pub(crate) async fn stand_in() -> (Client, Posted) { let held: Posted = Arc::default(); let state = Arc::clone(&held); let app = axum::Router::new().fallback( move |method: Method, uri: Uri, body: axum::body::Bytes| { let state = Arc::clone(&state); async move { let path = uri.path().to_owned(); let mut held = state.lock().unwrap(); match method.as_str() { "GET" if path == "/api/v1/user" => ( StatusCode::OK, [("x-total-count", String::new())], r#"{"login":"bot"}"#.to_owned(), ), "GET" => { let list: Vec<_> = held .iter() .enumerate() .filter(|(_, (p, _))| *p == path) .map(|(id, (_, body))| { let author = if body.starts_with(HUMAN) { "human" } else { "bot" }; serde_json::json!({ "id": id, "body": body, "user": { "login": author } }) }) .collect(); let count = list.len().to_string(); ( StatusCode::OK, [("x-total-count", count)], serde_json::Value::from(list).to_string(), ) } "PATCH" => { let sent: serde_json::Value = serde_json::from_slice(&body).unwrap(); let id: usize = path.rsplit('/').next().unwrap().parse().unwrap(); if held[id].1.contains(REFUSED) { return ( StatusCode::FORBIDDEN, [("x-total-count", String::new())], r#"{"message":"refused"}"#.to_owned(), ); } held[id].1 = sent["body"].as_str().unwrap().to_owned(); ( StatusCode::OK, [("x-total-count", String::new())], serde_json::json!({ "id": id, "body": held[id].1 }).to_string(), ) } "POST" => { let sent: serde_json::Value = serde_json::from_slice(&body).unwrap(); let text = sent["body"].as_str().unwrap().to_owned(); held.push((path, text.clone())); ( StatusCode::CREATED, [("x-total-count", String::new())], serde_json::json!({ "id": held.len() - 1, "body": text }) .to_string(), ) } _ => ( StatusCode::NOT_FOUND, [("x-total-count", String::new())], r#"{"message":"no"}"#.to_owned(), ), } } }, ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap(); tokio::spawn(async move { axum::serve(listener, app).await }); (Client::for_test_forge(url), held) } #[tokio::test] async fn a_marker_is_commented_once_and_a_new_marker_again() { let (client, held) = stand_in().await; let once = |marker: &'static str| { let client = &client; async move { client .comment_once("o", "r", 7, marker, &format!("{marker} text")) .await .unwrap() } }; assert!(once("").await, "first comment is posted"); assert!(!once("").await, "same marker is not posted again"); assert!(once("").await, "control: another marker posts"); let held = held.lock().unwrap(); assert_eq!(held.len(), 2, "{held:?}"); assert_eq!( held[0].0, format!("/api/v1/repos/o/r/{}/7/comments", "issues") ); } fn issue_path() -> String { format!("/api/v1/repos/o/r/{}/7/comments", "issues") } #[tokio::test] async fn a_cleared_marker_can_be_posted_again_and_the_old_comment_says_resolved() { let (client, held) = stand_in().await; let (marker, cleared) = ("", ""); assert!( client .comment_once("o", "r", 7, marker, marker) .await .unwrap() ); let clear = || client.clear_marker("o", "r", 7, marker, cleared, "Resolved."); assert_eq!(clear().await.unwrap(), 1); assert_eq!(clear().await.unwrap(), 0); assert!( client .comment_once("o", "r", 7, marker, marker) .await .unwrap() ); let held = held.lock().unwrap(); assert_eq!(held[0].1, format!("{cleared}\n\nResolved.")); assert_eq!(held[1].1, marker); } /// A human's comment can carry the marker (a quote-reply copies it): it /// neither counts as the bot's report nor is rewritten. #[tokio::test] async fn a_comment_by_someone_else_with_the_marker_is_neither_counted_nor_edited() { let (client, held) = stand_in().await; let marker = ""; let quoted = format!("{HUMAN} > {marker}"); held.lock().unwrap().push((issue_path(), quoted.clone())); assert!( client .comment_once("o", "r", 7, marker, marker) .await .unwrap(), "the human's copy does not silence the bot" ); let cleared = client .clear_marker("o", "r", 7, marker, "", "Resolved.") .await .unwrap(); assert_eq!(cleared, 1, "only the bot's own comment"); let held = held.lock().unwrap(); assert_eq!(held[0].1, quoted, "the human's comment is untouched"); assert!(held[1].1.contains(""), "{:?}", held[1]); } /// The forge refusing one edit does not stop the next comment's clear. #[tokio::test] async fn a_refused_edit_still_clears_the_next_comment() { let (client, held) = stand_in().await; let marker = ""; let refused = format!("{marker} {REFUSED}"); { let mut held = held.lock().unwrap(); held.push((issue_path(), refused.clone())); held.push((issue_path(), marker.to_owned())); } let cleared = client .clear_marker("o", "r", 7, marker, "", "Resolved.") .await .unwrap(); assert_eq!(cleared, 1); let held = held.lock().unwrap(); assert_eq!(held[0].1, refused, "control: the refused one is as it was"); assert!(held[1].1.contains(""), "{:?}", held[1]); } }