Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/forge/pr_comment.rs

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]);
}
}