Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ fields as `[defaults]`, plus:
| `patchwork.enabled` | bool | `false` | Enable Patchwork integration for this subsystem. |
| `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.user_agent` | string | -- | HTTP User-Agent sent with Patchwork API lookup and check requests. Set this when the instance requires an identifiable client. |
| `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. |
Expand Down Expand Up @@ -295,6 +296,7 @@ permissions (state changes, delegation, etc.), not just check access.
enabled = true
api_url = "https://patchwork.kernel.org/api/1.3"
token = "your-api-token" # or set SASHIKO_PATCHWORK_TOKEN env var
user_agent = "your-service/1.0 (contact@example.org)"
```

**Email mode** sends a structured notification email to a bot address.
Expand Down
1 change: 1 addition & 0 deletions docs/examples/email_policy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ send_positive_review = false
# enabled = true
# api_url = "https://patchwork.kernel.org/api/1.3"
# token = "your-api-token"
# user_agent = "your-service/1.0 (contact@example.org)"

# Email mode: sends a structured notification email that a local script
# (e.g. pw_tools) can parse and use to post the check.
Expand Down
8 changes: 8 additions & 0 deletions src/email_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ pub struct PatchworkPolicy {
pub enabled: bool,
pub api_url: Option<String>,
pub token: Option<String>,
/// HTTP User-Agent sent to the Patchwork API.
pub user_agent: Option<String>,
pub email: Option<String>,
/// Minimum finding severity to include in patchwork checks.
/// Findings below this threshold are excluded from the check
Expand All @@ -35,6 +37,7 @@ impl Default for PatchworkPolicy {
enabled: false,
api_url: None,
token: None,
user_agent: None,
email: None,
min_severity: None,
fail_severity: default_fail_severity(),
Expand Down Expand Up @@ -208,6 +211,7 @@ mod tests {
[subsystems.net.patchwork]
enabled = true
api_url = "https://patchwork.kernel.org/api/1.2"
user_agent = "sashiko-test/1.0 (test@example.org)"
"#;

let mut file = NamedTempFile::new().unwrap();
Expand Down Expand Up @@ -242,6 +246,10 @@ mod tests {
net_policy.patchwork.api_url.as_deref(),
Some("https://patchwork.kernel.org/api/1.2")
);
assert_eq!(
net_policy.patchwork.user_agent.as_deref(),
Some("sashiko-test/1.0 (test@example.org)")
);
}

#[test]
Expand Down
4 changes: 4 additions & 0 deletions src/email_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ mod tests {
enabled: true,
api_url: Some("https://patchwork.kernel.org/api".to_string()),
token: Some("token_mm".to_string()),
user_agent: None,
email: Some("notify@kernel.org".to_string()),
min_severity: Some("Medium".to_string()),
fail_severity: "High".to_string(),
Expand All @@ -607,6 +608,7 @@ mod tests {
enabled: true,
api_url: Some("https://patchwork.kernel.org/api".to_string()),
token: Some("token_bpf".to_string()),
user_agent: None,
email: None,
min_severity: Some("High".to_string()),
fail_severity: "Critical".to_string(),
Expand All @@ -624,6 +626,7 @@ mod tests {
enabled: true,
api_url: None,
token: None,
user_agent: None,
email: Some("notify@kernel.org".to_string()),
min_severity: None, // most inclusive
fail_severity: "High".to_string(),
Expand All @@ -639,6 +642,7 @@ mod tests {
enabled: false,
api_url: None,
token: None,
user_agent: None,
email: None,
min_severity: None,
fail_severity: "High".to_string(),
Expand Down
104 changes: 101 additions & 3 deletions src/patchwork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ struct PatchworkCheckRequest {
context: String,
}

/// Headers that identify Sashiko to a Patchwork API instance.
#[derive(Clone, Copy, Debug, Default)]
pub struct PatchworkApiIdentity<'a> {
pub token: Option<&'a str>,
pub user_agent: Option<&'a str>,
}

/// Post a check result to the Patchwork REST API for a given patch.
///
/// Looks up the patch by message-ID, then POSTs the check. Returns Ok
Expand All @@ -166,7 +173,7 @@ struct PatchworkCheckRequest {
pub async fn post_patchwork_check(
client: &Client,
api_url: &str,
token: Option<&str>,
identity: PatchworkApiIdentity<'_>,
msgid: &str,
status: &str,
description: &str,
Expand All @@ -184,9 +191,12 @@ pub async fn post_patchwork_check(
debug!("Fetching Patchwork patch by msgid: {}", clean_msgid);

let mut get_req = client.get(patches_url);
if let Some(token) = token {
if let Some(token) = identity.token {
get_req = get_req.header(header::AUTHORIZATION, format!("Token {}", token));
}
if let Some(user_agent) = identity.user_agent {
get_req = get_req.header(header::USER_AGENT, user_agent);
}

let resp = get_req
.send()
Expand Down Expand Up @@ -231,9 +241,12 @@ pub async fn post_patchwork_check(
debug!("Posting check to Patchwork: {} {:?}", check_url, payload);

let mut post_req = client.post(&check_url).json(&payload);
if let Some(token) = token {
if let Some(token) = identity.token {
post_req = post_req.header(header::AUTHORIZATION, format!("Token {}", token));
}
if let Some(user_agent) = identity.user_agent {
post_req = post_req.header(header::USER_AGENT, user_agent);
}

let post_resp = post_req
.send()
Expand Down Expand Up @@ -276,6 +289,8 @@ pub fn compose_patchwork_email(
mod tests {
use super::*;
use crate::email_policy::PatchworkPolicy;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;

fn finding(severity: &str, preexisting: bool) -> Value {
serde_json::json!({"severity": severity, "problem": "test", "preexisting": preexisting})
Expand All @@ -293,6 +308,40 @@ mod tests {
PatchworkPolicy::default()
}

async fn capture_request(listener: &TcpListener, response: &str) -> String {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];

loop {
let bytes_read = stream.read(&mut buffer).await.unwrap();
assert!(bytes_read > 0, "connection closed before request headers");
request.extend_from_slice(&buffer[..bytes_read]);

if let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = request_header(&headers, "content-length")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
if request.len() >= header_end + 4 + content_length {
break;
}
}
}

stream.write_all(response.as_bytes()).await.unwrap();
String::from_utf8(request).unwrap()
}

fn request_header<'a>(request: &'a str, name: &str) -> Option<&'a str> {
request.lines().skip(1).find_map(|line| {
let (header_name, value) = line.split_once(':')?;
header_name
.eq_ignore_ascii_case(name)
.then_some(value.trim())
})
}

// -- PatchworkCheckResult tests --

#[test]
Expand Down Expand Up @@ -461,6 +510,55 @@ mod tests {
assert!(result.description.contains("\u{00b7}")); // middle dot
}

#[tokio::test]
async fn test_patchwork_user_agent_sent_on_lookup_and_check() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let lookup = capture_request(
&listener,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 10\r\nConnection: close\r\n\r\n[{\"id\":7}]",
)
.await;
let check = capture_request(
&listener,
"HTTP/1.1 201 Created\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.await;
(lookup, check)
});

let api_url = format!("http://{address}/api/1.3");
post_patchwork_check(
&Client::new(),
&api_url,
PatchworkApiIdentity {
token: Some("test-token"),
user_agent: Some("sashiko-test/1.0 (test@example.org)"),
},
"<patch@example.org>",
"success",
"No regressions",
"https://sashiko.dev/review/7",
)
.await
.unwrap();

let (lookup, check) = server.await.unwrap();
assert!(lookup.starts_with("GET /api/1.3/patches/?msgid=patch%40example.org HTTP/1.1"));
assert!(check.starts_with("POST /api/1.3/patches/7/checks/ HTTP/1.1"));
for request in [&lookup, &check] {
assert_eq!(
request_header(request, "user-agent"),
Some("sashiko-test/1.0 (test@example.org)")
);
assert_eq!(
request_header(request, "authorization"),
Some("Token test-token")
);
}
}

// -- compose_patchwork_email tests --

#[test]
Expand Down
32 changes: 18 additions & 14 deletions src/worker/patchwork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// limitations under the License.

use crate::db::Database;
use crate::email_policy::EmailPolicyConfig;
use crate::email_policy::{EmailPolicyConfig, PatchworkPolicy};
use std::sync::Arc;
use std::time::Duration;
use tokio::time::sleep;
Expand All @@ -34,27 +34,27 @@ impl PatchworkWorker {
}
}

/// Resolve the patchwork API token for a given api_url by loading
/// the email policy config and matching against subsystem policies.
/// Tokens are never stored in the database -- they are resolved
/// from the config file (and SASHIKO_PATCHWORK_TOKEN env var) at
/// delivery time.
fn resolve_token(&self, api_url: &str) -> Option<String> {
/// Resolve the Patchwork API policy for a given api_url.
///
/// Credentials and request identification are deliberately resolved
/// from the config at delivery time instead of being stored in the
/// outbox.
fn resolve_api_policy(&self, api_url: &str) -> Option<PatchworkPolicy> {
let config = EmailPolicyConfig::load(&self.email_policy_path)
.expect("Failed to load email policy for token resolution");
.expect("Failed to load email policy for Patchwork delivery");

// Check subsystem policies for a matching api_url
for sub in config.subsystems.values() {
if sub.patchwork.enabled && sub.patchwork.api_url.as_deref() == Some(api_url) {
return sub.patchwork.token.clone();
return Some(sub.patchwork.clone());
}
}

// Fall back to defaults
if config.defaults.patchwork.enabled
&& config.defaults.patchwork.api_url.as_deref() == Some(api_url)
{
return config.defaults.patchwork.token.clone();
return Some(config.defaults.patchwork);
}

None
Expand Down Expand Up @@ -84,14 +84,18 @@ impl PatchworkWorker {
entry.id, entry.patch_msg_id
);

// Resolve the token from config at delivery time,
// not from the database row.
let token = self.resolve_token(&entry.api_url);
let api_policy = self.resolve_api_policy(&entry.api_url);
let token = api_policy
.as_ref()
.and_then(|policy| policy.token.as_deref());
let user_agent = api_policy
.as_ref()
.and_then(|policy| policy.user_agent.as_deref());

match crate::patchwork::post_patchwork_check(
&client,
&entry.api_url,
token.as_deref(),
crate::patchwork::PatchworkApiIdentity { token, user_agent },
&entry.patch_msg_id,
&entry.check_state,
&entry.description,
Expand Down