330 lines
13 KiB
Rust
330 lines
13 KiB
Rust
//! 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<Vec<Comment>> {
|
|
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<Vec<Comment>> {
|
|
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<bool> {
|
|
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<usize> {
|
|
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<Mutex<Vec<(String, String)>>>;
|
|
|
|
/// 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("<!-- a -->").await, "first comment is posted");
|
|
assert!(!once("<!-- a -->").await, "same marker is not posted again");
|
|
assert!(once("<!-- b -->").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) = ("<!-- a -->", "<!-- a 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 = "<!-- a -->";
|
|
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, "<!-- c -->", "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("<!-- c -->"), "{:?}", 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 = "<!-- a -->";
|
|
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, "<!-- c -->", "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("<!-- c -->"), "{:?}", held[1]);
|
|
}
|
|
}
|