Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 87 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,92 @@ fields as `[defaults]`, plus:
|-----|------|---------|-------------|
| `lists` | list | `[]` | Mailing list addresses that map to this subsystem. |
| `patchwork.enabled` | bool | `false` | Enable Patchwork integration for this subsystem. |
| `patchwork.api_url` | string | -- | Patchwork API URL. |
| `patchwork.token` | string | -- | Patchwork API token. |
| `patchwork.api_url` | string | -- | Patchwork REST API URL (e.g. `https://patchwork.kernel.org/api/1.3`). Trailing slashes are stripped automatically. Invalid schemes are rejected with a warning. |
| `patchwork.token` | string | -- | Patchwork API token. Can also be set via `SASHIKO_PATCHWORK_TOKEN` env var (fills in where token is omitted in TOML). |
| `patchwork.email` | string | -- | Email address for email-based Patchwork notifications. |
| `patchwork.min_severity` | string | -- | Minimum finding severity to include in patchwork checks. Findings below this threshold are excluded. Accepts: `Low`, `Medium`, `High`, `Critical` (case-insensitive). Default: all findings included. |
| `patchwork.fail_severity` | string | `High` | Minimum severity of NEW findings that triggers the `fail` check state instead of `warning`. New findings at or above this threshold produce `fail`; below it produce `warning`. Pre-existing findings never affect the check state. |

### Patchwork integration

Sashiko can report review results as
[checks](https://patchwork.readthedocs.io/en/latest/usage/overview/#checks)
on a Patchwork instance. Two delivery modes are available and can be
enabled simultaneously for the same subsystem.

**API mode** posts checks directly to the Patchwork REST API with
retry-queuing (3 attempts, exponential backoff). Requires a maintainer
API token. Note: Patchwork tokens grant full project-maintainer
permissions (state changes, delegation, etc.), not just check access.

```toml
[subsystems.net.patchwork]
enabled = true
api_url = "https://patchwork.kernel.org/api/1.3"
token = "your-api-token" # or set SASHIKO_PATCHWORK_TOKEN env var
```

**Email mode** sends a structured notification email to a bot address.
A local script (such as
[pw_tools](https://github.com/mchehab/pw_tools)) parses the email and
posts the check. This avoids giving Sashiko a write token.

```toml
[subsystems.linux-media.patchwork]
enabled = true
email = "pw-bot@lists.example.org"
```

#### Severity filtering and check state mapping

By default, all findings are included in the patchwork check count.
Set `min_severity` to exclude findings below a threshold. When all
findings fall below the threshold, the check is posted as `success`.

The check state depends only on **new** findings (not pre-existing):

- `fail` -- new findings at or above `fail_severity` (default: `High`)
- `warning` -- new findings below `fail_severity`
- `success` -- no new findings (pre-existing findings are still
shown in the description but do not affect the state)

The check description shows a per-severity breakdown with
pre-existing counts in parentheses, dropping zero-count severities.
For example: `Critical: 1 · High: 2 (1 pre-existing)`.

```toml
[subsystems.net.patchwork]
enabled = true
api_url = "https://patchwork.kernel.org/api/1.3"
min_severity = "Medium" # exclude Low findings entirely
fail_severity = "High" # High+ new findings = fail (default)
```

Edge case behaviors:

- Missing or null `preexisting` flag on a finding is treated as new
- When `min_severity` filters out all findings, the check is `success`
with "Sashiko AI review found no regressions"
- When only pre-existing findings remain after filtering, the check
is `success` but the description shows the pre-existing breakdown

#### Email notification format

When email mode is enabled, Sashiko sends a plain-text email with:

- **To**: the configured `patchwork.email` address
- **Subject**: `[sashiko-check] {status} - {patch_subject}`
- **Body** (one key-value pair per line):

```
msgid: <message-id>
status: success|warning
description: Sashiko AI review found N potential issue(s)
target_url: https://sashiko.dev/#/patchset/...
context: sashiko
```

Downstream tools can parse this format with simple line splitting.

## Environment variables

Expand All @@ -196,5 +280,6 @@ fields as `[defaults]`, plus:
| `CLOUD_ML_REGION` | GCP region for Vertex AI provider. |
| `SASHIKO_SERVER` | Override daemon URL for CLI commands. |
| `SASHIKO__*` | Override any Settings.toml value (e.g. `SASHIKO__AI__PROVIDER`). |
| `SASHIKO_PATCHWORK_TOKEN` | Patchwork API token. Fills in `patchwork.token` for enabled subsystems that have `api_url` set but no explicit token in TOML. |
| `NO_COLOR` | Disable ANSI color output. |
| `SASHIKO_LOG_PLAIN` | Use plain log format (no level/target/timestamp). |
37 changes: 37 additions & 0 deletions docs/examples/email_policy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,40 @@ ignored_emails = []
# If true, Sashiko will send a reply indicating that the review was positive.
send_positive_review = false

# --- Patchwork integration examples ---
#
# API mode: direct REST API calls with retry-queuing (3 attempts).
# Requires a maintainer API token. The token can also be injected via
# the SASHIKO_PATCHWORK_TOKEN environment variable.
#
# [subsystems.net.patchwork]
# enabled = true
# api_url = "https://patchwork.kernel.org/api/1.3"
# token = "your-api-token"

# Email mode: sends a structured notification email that a local script
# (e.g. pw_tools) can parse and use to post the check.
#
# [subsystems.linux-media.patchwork]
# enabled = true
# email = "pw-bot@lists.example.org"

# Both modes simultaneously:
#
# [subsystems.dri-devel.patchwork]
# enabled = true
# api_url = "https://patchwork.freedesktop.org/api/1.3"
# token = "your-api-token"
# email = "backup-bot@lists.example.org"

# Severity filtering and check state mapping:
# min_severity excludes findings below the threshold entirely.
# fail_severity controls which new findings trigger "fail" vs "warning".
# Pre-existing findings are shown in the description but never affect state.
# Default fail_severity is "High" (High+ new = fail, Medium/Low new = warning).
#
# [subsystems.net.patchwork]
# enabled = true
# api_url = "https://patchwork.kernel.org/api/1.3"
# min_severity = "Medium"
# fail_severity = "High"
182 changes: 180 additions & 2 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ pub struct Finding {

pub struct EmailOutboxRow {
pub id: i64,
pub patch_id: i64,
pub patch_id: Option<i64>,
pub status: String,
pub to_addresses: String,
pub cc_addresses: String,
Expand All @@ -160,6 +160,22 @@ pub struct EmailOutboxRow {
pub created_at: i64,
}

pub struct PatchworkOutboxRow {
pub id: i64,
pub patch_msg_id: String,
pub api_url: String,
pub check_state: String,
pub description: String,
pub target_url: String,
pub context: String,
pub status: String,
pub retry_count: i64,
pub next_retry_at: Option<i64>,
pub locked_at: Option<i64>,
pub error_log: Option<String>,
pub created_at: i64,
}

impl Database {
pub async fn get_oldest_message_timestamp(&self) -> Result<Option<i64>> {
let mut rows = self
Expand Down Expand Up @@ -3766,7 +3782,7 @@ impl Database {

if let Ok(Some(row)) = rows.next().await {
let id: i64 = row.get(0)?;
let patch_id: i64 = row.get(1)?;
let patch_id: Option<i64> = row.get::<i64>(1).ok();
let status: String = row.get(2)?;
let to_addresses: String = row.get(3)?;
let cc_addresses: String = row.get(4)?;
Expand Down Expand Up @@ -3820,6 +3836,168 @@ impl Database {
).await?;
Ok(count)
}

// -- Patchwork outbox operations --

pub async fn insert_patchwork_outbox(
&self,
patch_msg_id: &str,
api_url: &str,
check_state: &str,
description: &str,
target_url: &str,
context: &str,
) -> Result<()> {
let created_at = chrono::Utc::now().timestamp();
self.conn
.execute(
"INSERT INTO patchwork_outbox (patch_msg_id, api_url, check_state, description, target_url, context, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
libsql::params![
patch_msg_id,
api_url,
check_state,
description,
target_url,
context,
created_at,
],
)
.await?;
Ok(())
}

pub async fn lock_pending_patchwork(&self) -> Result<Option<PatchworkOutboxRow>> {
let now = chrono::Utc::now().timestamp();
let mut rows = self
.conn
.query(
"UPDATE patchwork_outbox
SET status = 'Sending', locked_at = ?
WHERE id = (
SELECT id FROM patchwork_outbox
WHERE status = 'Pending'
AND (next_retry_at IS NULL OR next_retry_at <= ?)
LIMIT 1
)
RETURNING id, patch_msg_id, api_url, check_state, description, target_url, context, status, retry_count, next_retry_at, locked_at, error_log, created_at",
libsql::params![now, now],
)
.await?;

if let Ok(Some(row)) = rows.next().await {
let id: i64 = row.get(0)?;
let patch_msg_id: String = row.get(1)?;
let api_url: String = row.get(2)?;
let check_state: String = row.get(3)?;
let description: String = row.get(4)?;
let target_url: String = row.get(5)?;
let context: String = row.get(6)?;
let status: String = row.get(7)?;
let retry_count: i64 = row.get(8)?;
let next_retry_at: Option<i64> = row.get::<i64>(9).ok();
let locked_at: Option<i64> = row.get::<i64>(10).ok();
let error_log: Option<String> = row.get::<String>(11).ok();
let created_at: i64 = row.get(12)?;

Ok(Some(PatchworkOutboxRow {
id,
patch_msg_id,
api_url,
check_state,
description,
target_url,
context,
status,
retry_count,
next_retry_at,
locked_at,
error_log,
created_at,
}))
} else {
Ok(None)
}
}

pub async fn mark_patchwork_sent(&self, id: i64) -> Result<()> {
self.conn
.execute(
"UPDATE patchwork_outbox SET status = 'Sent', locked_at = NULL WHERE id = ?",
libsql::params![id],
)
.await?;
Ok(())
}

pub async fn mark_patchwork_failed(&self, id: i64, error_log: &str) -> Result<()> {
self.conn
.execute(
"UPDATE patchwork_outbox SET status = 'Failed', error_log = ?, locked_at = NULL WHERE id = ?",
libsql::params![error_log.to_string(), id],
)
.await?;
Ok(())
}

/// Mark a patchwork outbox entry for retry at a future timestamp.
/// Increments retry_count, sets next_retry_at, and returns to
/// Pending status so the worker loop continues without blocking.
pub async fn set_patchwork_retry_at(&self, id: i64, next_retry_at: i64) -> Result<()> {
self.conn
.execute(
"UPDATE patchwork_outbox SET status = 'Pending', retry_count = retry_count + 1, next_retry_at = ?, locked_at = NULL WHERE id = ?",
libsql::params![next_retry_at, id],
)
.await?;
Ok(())
}

pub async fn sweep_ghost_patchwork(&self) -> Result<u64> {
let ten_mins_ago = chrono::Utc::now().timestamp() - 600;
let count = self.conn
.execute(
"UPDATE patchwork_outbox SET status = 'Pending', locked_at = NULL WHERE status = 'Sending' AND locked_at < ?",
libsql::params![ten_mins_ago],
)
.await?;
Ok(count)
}

/// Insert a patchwork notification email into the email outbox.
///
/// Uses patch_id = NULL to avoid colliding with the per-patch dedup
/// guard in insert_email_outbox(). The EmailWorker processes these
/// rows normally since it picks up any row with status = 'Pending'.
pub async fn insert_patchwork_notification(
&self,
status: &str,
to_address: &str,
subject: &str,
in_reply_to: &str,
references_hdr: &str,
body: &str,
) -> Result<()> {
let created_at = chrono::Utc::now().timestamp();
let to_json = serde_json::to_string(&[to_address])
.map_err(|e| libsql::Error::Misuse(e.to_string()))?;
self.conn
.execute(
"INSERT INTO email_outbox (patch_id, status, to_addresses, cc_addresses, subject, in_reply_to, references_hdr, body, created_at)
VALUES (NULL, ?, ?, '[]', ?, ?, ?, ?, ?)",
libsql::params![
status,
to_json,
subject,
in_reply_to,
references_hdr,
body,
created_at,
],
)
.await?;
Ok(())
}
}

#[cfg(test)]
Expand Down
Loading