All of lore.kernel.org
 help / color / mirror / Atom feed
From: Arnaldo Carvalho de Melo <acme@kernel.org>
To: Roman Gushchin <roman.gushchin@linux.dev>
Cc: sashiko@lists.linux.dev, Derek Barbosa <debarbos@redhat.com>,
	Clark Williams <williams@redhat.com>
Subject: [PATCH 1/1] cli: Add cancel command to abort pending reviews
Date: Sun, 19 Apr 2026 11:39:28 -0300	[thread overview]
Message-ID: <aeTpIFdgGhuyYgcZ@x1> (raw)

Hi,

	I still need to setup a github repo, see if this is acceptable,
first patch, ran clippy, rust-fmt, tests passed, works for me :-)

- Arnaldo

---

Add `sashiko-cli cancel <id>` to cancel patchsets in Pending or
Incomplete state. With `--force`, also cancels patchsets already
In Review. The reviewer now checks for Cancelled status before
writing its final result, preventing a force-cancelled review
from being overwritten.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Arnaldo Carvalho de Melo <acme@redhat.com>
---
 src/api.rs             | 46 ++++++++++++++++++++++++++++++++++++++
 src/bin/sashiko-cli.rs | 50 ++++++++++++++++++++++++++++++++++++++++++
 src/db.rs              | 25 +++++++++++++++++++++
 src/reviewer.rs        | 24 +++++++++++++-------
 4 files changed, 137 insertions(+), 8 deletions(-)

diff --git a/src/api.rs b/src/api.rs
index 2c132b7cd0e6a469..d43f225c37fe1e6e 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -186,6 +186,13 @@ pub struct SubsystemQuery {
     pub subsystem_id: Option<i64>,
 }
 
+#[derive(Deserialize)]
+pub struct CancelQuery {
+    pub id: i64,
+    #[serde(default)]
+    pub force: bool,
+}
+
 #[derive(Deserialize)]
 pub struct InjectRequest {
     pub raw: String,
@@ -267,6 +274,7 @@ pub async fn run_server(
         .route("/api/stats/tools", get(stats_tools))
         .route("/api/submit", post(submit_patch))
         .route("/api/patchset/rerun", post(rerun_patchset))
+        .route("/api/patchset/cancel", post(cancel_patchset))
         .route("/api/patch/rerun", post(rerun_patch))
         .route("/", get_service(ServeFile::new("static/index.html")))
         .nest_service("/static", ServeDir::new("static"))
@@ -931,6 +939,44 @@ async fn rerun_patchset(
     Ok(Json(serde_json::json!({ "status": "accepted" })))
 }
 
+async fn cancel_patchset(
+    ConnectInfo(addr): ConnectInfo<SocketAddr>,
+    State(state): State<Arc<AppState>>,
+    Query(query): Query<CancelQuery>,
+) -> Result<Json<serde_json::Value>, StatusCode> {
+    if state.read_only {
+        return Err(StatusCode::FORBIDDEN);
+    }
+
+    if !state.allow_all_submit && !addr.ip().is_loopback() {
+        return Err(StatusCode::FORBIDDEN);
+    }
+
+    let cancelled = state
+        .db
+        .cancel_patchset(query.id, query.force)
+        .await
+        .map_err(|e| {
+            error!("Failed to cancel patchset {}: {}", query.id, e);
+            StatusCode::INTERNAL_SERVER_ERROR
+        })?;
+
+    if cancelled {
+        info!("Patchset {} cancelled (force={})", query.id, query.force);
+        Ok(Json(serde_json::json!({ "status": "cancelled" })))
+    } else {
+        let reason = if query.force {
+            "Patchset is not in a cancellable state (must be Pending, Incomplete, or In Review)"
+        } else {
+            "Patchset is not in a cancellable state (must be Pending or Incomplete; use force=true for In Review)"
+        };
+        Ok(Json(serde_json::json!({
+            "status": "not_modified",
+            "reason": reason
+        })))
+    }
+}
+
 async fn rerun_patch(
     ConnectInfo(addr): ConnectInfo<SocketAddr>,
     State(state): State<Arc<AppState>>,
diff --git a/src/bin/sashiko-cli.rs b/src/bin/sashiko-cli.rs
index 09a2ce63fad44376..4a60dc51df0f8567 100644
--- a/src/bin/sashiko-cli.rs
+++ b/src/bin/sashiko-cli.rs
@@ -97,6 +97,15 @@ enum Commands {
         #[arg(default_value = "latest")]
         id: String,
     },
+    /// Cancel a pending review
+    Cancel {
+        /// ID of the patchset to cancel
+        id: i64,
+
+        /// Force cancel even if the review is already in progress
+        #[arg(long, short)]
+        force: bool,
+    },
 }
 
 #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
@@ -182,6 +191,7 @@ async fn run_command(
             per_page,
         } => handle_list(client, base_url, page, per_page, filter, format).await,
         Commands::Show { id } => handle_show(client, base_url, id, format).await,
+        Commands::Cancel { id, force } => handle_cancel(client, base_url, id, force, format).await,
     }
 }
 
@@ -389,6 +399,7 @@ async fn handle_list(
                         "Embargoed" => Color::Magenta,
                         "Failed" | "Error" | "Failed To Apply" => Color::Red,
                         "Pending" | "In Review" => Color::Yellow,
+                        "Cancelled" => Color::Red,
                         _ => Color::White,
                     };
 
@@ -672,6 +683,45 @@ async fn handle_show(
     Ok(())
 }
 
+async fn handle_cancel(
+    client: &Client,
+    base_url: &str,
+    id: i64,
+    force: bool,
+    format: OutputFormat,
+) -> Result<()> {
+    let url = format!("{}/api/patchset/cancel?id={}&force={}", base_url, id, force);
+    let resp = client.post(&url).send().await?;
+
+    if resp.status().is_success() {
+        let result: serde_json::Value = resp.json().await?;
+        match format {
+            OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&result)?),
+            OutputFormat::Text => {
+                let status = result["status"].as_str().unwrap_or("");
+                if status == "cancelled" {
+                    print_colored(Color::Green, "Cancelled: ");
+                    println!("Patchset {} has been cancelled.", id);
+                } else {
+                    print_colored(Color::Yellow, "Not modified: ");
+                    println!(
+                        "{}",
+                        result["reason"]
+                            .as_str()
+                            .unwrap_or("Patchset could not be cancelled.")
+                    );
+                }
+            }
+        }
+    } else {
+        let status = resp.status();
+        let text = resp.text().await.unwrap_or_default();
+        return Err(anyhow::anyhow!("Cancel failed ({}): {}", status, text));
+    }
+
+    Ok(())
+}
+
 fn print_colored(color: Color, text: &str) {
     let mut stdout = StandardStream::stdout(ColorChoice::Auto);
     stdout
diff --git a/src/db.rs b/src/db.rs
index 60af07cbbb9c5278..178d6c154e4e77d9 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -3205,6 +3205,31 @@ impl Database {
         Ok(())
     }
 
+    pub async fn get_patchset_status(&self, id: i64) -> Result<Option<String>> {
+        let mut rows = self
+            .conn
+            .query(
+                "SELECT status FROM patchsets WHERE id = ?",
+                libsql::params![id],
+            )
+            .await?;
+        if let Some(row) = rows.next().await? {
+            Ok(Some(row.get(0)?))
+        } else {
+            Ok(None)
+        }
+    }
+
+    pub async fn cancel_patchset(&self, id: i64, force: bool) -> Result<bool> {
+        let query = if force {
+            "UPDATE patchsets SET status = 'Cancelled' WHERE id = ? AND status IN ('Pending', 'Incomplete', 'In Review')"
+        } else {
+            "UPDATE patchsets SET status = 'Cancelled' WHERE id = ? AND status IN ('Pending', 'Incomplete')"
+        };
+        let count = self.conn.execute(query, libsql::params![id]).await?;
+        Ok(count > 0)
+    }
+
     pub async fn restart_failed_reviews(&self) -> Result<u64> {
         let count = self.conn.execute(
             "UPDATE patchsets SET status = 'Pending', failed_reason = NULL WHERE status = 'Failed'",
diff --git a/src/reviewer.rs b/src/reviewer.rs
index d99f23fe93a35731..9007dc289bc86925 100644
--- a/src/reviewer.rs
+++ b/src/reviewer.rs
@@ -644,16 +644,24 @@ impl Reviewer {
             // Cleanup worktree here since we kept it alive for reuse
             let _ = worktree.remove().await;
 
-            let final_status = if review_success {
-                ReviewStatus::Reviewed.as_str().to_string()
+            let current_status = ctx.db.get_patchset_status(patchset_id).await.ok().flatten();
+            if current_status.as_deref() == Some(ReviewStatus::Cancelled.as_str()) {
+                info!(
+                    "Patchset {} was cancelled during review, preserving status",
+                    patchset_id
+                );
             } else {
-                ReviewStatus::Failed.as_str().to_string()
-            };
+                let final_status = if review_success {
+                    ReviewStatus::Reviewed.as_str().to_string()
+                } else {
+                    ReviewStatus::Failed.as_str().to_string()
+                };
 
-            let _ = ctx
-                .db
-                .update_patchset_status(patchset_id, &final_status)
-                .await;
+                let _ = ctx
+                    .db
+                    .update_patchset_status(patchset_id, &final_status)
+                    .await;
+            }
         } else {
             // No baseline found
             warn!("No working baseline found for patchset {}", patchset_id);
-- 
2.53.0


                 reply	other threads:[~2026-04-19 14:39 UTC|newest]

Thread overview: [no followups] expand[flat|nested]  mbox.gz  Atom feed

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=aeTpIFdgGhuyYgcZ@x1 \
    --to=acme@kernel.org \
    --cc=debarbos@redhat.com \
    --cc=roman.gushchin@linux.dev \
    --cc=sashiko@lists.linux.dev \
    --cc=williams@redhat.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.