mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 17:59:43 +08:00
feat: add ecc2 webhook notifications
This commit is contained in:
parent
b45a6ca810
commit
5fb2e62216
154
ecc2/Cargo.lock
generated
154
ecc2/Cargo.lock
generated
@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@ -300,6 +306,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
@ -507,6 +522,7 @@ dependencies = [
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@ -592,6 +608,16 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@ -1141,6 +1167,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@ -1612,6 +1648,20 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
@ -1661,6 +1711,41 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@ -1794,6 +1879,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
@ -1855,6 +1946,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@ -2208,6 +2305,30 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@ -2374,6 +2495,24 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wezterm-bidi"
|
||||
version = "0.2.3"
|
||||
@ -2527,6 +2666,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@ -2776,6 +2924,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
||||
@ -27,6 +27,7 @@ serde_json = "1"
|
||||
toml = "0.8"
|
||||
regex = "1"
|
||||
sha2 = "0.10"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
@ -3,7 +3,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::notifications::{CompletionSummaryConfig, DesktopNotificationConfig};
|
||||
use crate::notifications::{
|
||||
CompletionSummaryConfig, DesktopNotificationConfig, WebhookNotificationConfig,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@ -48,6 +50,7 @@ pub struct Config {
|
||||
pub auto_create_worktrees: bool,
|
||||
pub auto_merge_ready_worktrees: bool,
|
||||
pub desktop_notifications: DesktopNotificationConfig,
|
||||
pub webhook_notifications: WebhookNotificationConfig,
|
||||
pub completion_summary_notifications: CompletionSummaryConfig,
|
||||
pub cost_budget_usd: f64,
|
||||
pub token_budget: u64,
|
||||
@ -112,6 +115,7 @@ impl Default for Config {
|
||||
auto_create_worktrees: true,
|
||||
auto_merge_ready_worktrees: false,
|
||||
desktop_notifications: DesktopNotificationConfig::default(),
|
||||
webhook_notifications: WebhookNotificationConfig::default(),
|
||||
completion_summary_notifications: CompletionSummaryConfig::default(),
|
||||
cost_budget_usd: 10.0,
|
||||
token_budget: 500_000,
|
||||
@ -438,6 +442,7 @@ theme = "Dark"
|
||||
defaults.auto_merge_ready_worktrees
|
||||
);
|
||||
assert_eq!(config.desktop_notifications, defaults.desktop_notifications);
|
||||
assert_eq!(config.webhook_notifications, defaults.webhook_notifications);
|
||||
assert_eq!(
|
||||
config.auto_terminate_stale_sessions,
|
||||
defaults.auto_terminate_stale_sessions
|
||||
@ -636,6 +641,42 @@ delivery = "desktop_and_tui_popup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_notifications_deserialize_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[webhook_notifications]
|
||||
enabled = true
|
||||
session_started = true
|
||||
session_completed = true
|
||||
session_failed = true
|
||||
budget_alerts = true
|
||||
approval_requests = false
|
||||
|
||||
[[webhook_notifications.targets]]
|
||||
provider = "slack"
|
||||
url = "https://hooks.slack.test/services/abc"
|
||||
|
||||
[[webhook_notifications.targets]]
|
||||
provider = "discord"
|
||||
url = "https://discord.test/api/webhooks/123"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(config.webhook_notifications.enabled);
|
||||
assert!(config.webhook_notifications.session_started);
|
||||
assert_eq!(config.webhook_notifications.targets.len(), 2);
|
||||
assert_eq!(
|
||||
config.webhook_notifications.targets[0].provider,
|
||||
crate::notifications::WebhookProvider::Slack
|
||||
);
|
||||
assert_eq!(
|
||||
config.webhook_notifications.targets[1].provider,
|
||||
crate::notifications::WebhookProvider::Discord
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_budget_alert_thresholds_fall_back_to_defaults() {
|
||||
let config: Config = toml::from_str(
|
||||
@ -663,6 +704,11 @@ critical = 1.10
|
||||
config.auto_create_worktrees = false;
|
||||
config.auto_merge_ready_worktrees = true;
|
||||
config.desktop_notifications.session_completed = false;
|
||||
config.webhook_notifications.enabled = true;
|
||||
config.webhook_notifications.targets = vec![crate::notifications::WebhookTarget {
|
||||
provider: crate::notifications::WebhookProvider::Slack,
|
||||
url: "https://hooks.slack.test/services/abc".to_string(),
|
||||
}];
|
||||
config.completion_summary_notifications.delivery =
|
||||
crate::notifications::CompletionSummaryDelivery::TuiPopup;
|
||||
config.desktop_notifications.quiet_hours.enabled = true;
|
||||
@ -688,6 +734,12 @@ critical = 1.10
|
||||
assert!(!loaded.auto_create_worktrees);
|
||||
assert!(loaded.auto_merge_ready_worktrees);
|
||||
assert!(!loaded.desktop_notifications.session_completed);
|
||||
assert!(loaded.webhook_notifications.enabled);
|
||||
assert_eq!(loaded.webhook_notifications.targets.len(), 1);
|
||||
assert_eq!(
|
||||
loaded.webhook_notifications.targets[0].provider,
|
||||
crate::notifications::WebhookProvider::Slack
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.completion_summary_notifications.delivery,
|
||||
crate::notifications::CompletionSummaryDelivery::TuiPopup
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Local, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use anyhow::Context;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotificationEvent {
|
||||
SessionStarted,
|
||||
SessionCompleted,
|
||||
SessionFailed,
|
||||
BudgetAlert,
|
||||
@ -25,6 +27,7 @@ pub struct QuietHoursConfig {
|
||||
#[serde(default)]
|
||||
pub struct DesktopNotificationConfig {
|
||||
pub enabled: bool,
|
||||
pub session_started: bool,
|
||||
pub session_completed: bool,
|
||||
pub session_failed: bool,
|
||||
pub budget_alerts: bool,
|
||||
@ -48,11 +51,43 @@ pub struct CompletionSummaryConfig {
|
||||
pub delivery: CompletionSummaryDelivery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WebhookProvider {
|
||||
#[default]
|
||||
Slack,
|
||||
Discord,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WebhookTarget {
|
||||
pub provider: WebhookProvider,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WebhookNotificationConfig {
|
||||
pub enabled: bool,
|
||||
pub session_started: bool,
|
||||
pub session_completed: bool,
|
||||
pub session_failed: bool,
|
||||
pub budget_alerts: bool,
|
||||
pub approval_requests: bool,
|
||||
pub targets: Vec<WebhookTarget>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopNotifier {
|
||||
config: DesktopNotificationConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebhookNotifier {
|
||||
config: WebhookNotificationConfig,
|
||||
}
|
||||
|
||||
impl Default for QuietHoursConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@ -96,6 +131,7 @@ impl Default for DesktopNotificationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
session_started: false,
|
||||
session_completed: true,
|
||||
session_failed: true,
|
||||
budget_alerts: true,
|
||||
@ -120,6 +156,7 @@ impl DesktopNotificationConfig {
|
||||
}
|
||||
|
||||
match event {
|
||||
NotificationEvent::SessionStarted => config.session_started,
|
||||
NotificationEvent::SessionCompleted => config.session_completed,
|
||||
NotificationEvent::SessionFailed => config.session_failed,
|
||||
NotificationEvent::BudgetAlert => config.budget_alerts,
|
||||
@ -155,6 +192,68 @@ impl CompletionSummaryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebhookTarget {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: WebhookProvider::Slack,
|
||||
url: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebhookTarget {
|
||||
fn sanitized(self) -> Option<Self> {
|
||||
let url = self.url.trim().to_string();
|
||||
if url.starts_with("https://") || url.starts_with("http://") {
|
||||
Some(Self { url, ..self })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WebhookNotificationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
session_started: true,
|
||||
session_completed: true,
|
||||
session_failed: true,
|
||||
budget_alerts: true,
|
||||
approval_requests: false,
|
||||
targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebhookNotificationConfig {
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
targets: self
|
||||
.targets
|
||||
.into_iter()
|
||||
.filter_map(WebhookTarget::sanitized)
|
||||
.collect(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allows(&self, event: NotificationEvent) -> bool {
|
||||
let config = self.clone().sanitized();
|
||||
if !config.enabled || config.targets.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
match event {
|
||||
NotificationEvent::SessionStarted => config.session_started,
|
||||
NotificationEvent::SessionCompleted => config.session_completed,
|
||||
NotificationEvent::SessionFailed => config.session_failed,
|
||||
NotificationEvent::BudgetAlert => config.budget_alerts,
|
||||
NotificationEvent::ApprovalRequest => config.approval_requests,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopNotifier {
|
||||
pub fn new(config: DesktopNotificationConfig) -> Self {
|
||||
Self {
|
||||
@ -192,6 +291,57 @@ impl DesktopNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
impl WebhookNotifier {
|
||||
pub fn new(config: WebhookNotificationConfig) -> Self {
|
||||
Self {
|
||||
config: config.sanitized(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify(&self, event: NotificationEvent, message: &str) -> bool {
|
||||
match self.try_notify(event, message) {
|
||||
Ok(sent) => sent,
|
||||
Err(error) => {
|
||||
tracing::warn!("Failed to send webhook notification: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_notify(&self, event: NotificationEvent, message: &str) -> Result<bool> {
|
||||
self.try_notify_with(event, message, send_webhook_request)
|
||||
}
|
||||
|
||||
fn try_notify_with<F>(
|
||||
&self,
|
||||
event: NotificationEvent,
|
||||
message: &str,
|
||||
mut sender: F,
|
||||
) -> Result<bool>
|
||||
where
|
||||
F: FnMut(&WebhookTarget, serde_json::Value) -> Result<()>,
|
||||
{
|
||||
if !self.config.allows(event) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut delivered = false;
|
||||
for target in &self.config.targets {
|
||||
let payload = webhook_payload(target, message);
|
||||
match sender(target, payload) {
|
||||
Ok(()) => delivered = true,
|
||||
Err(error) => tracing::warn!(
|
||||
"Failed to deliver {:?} webhook notification to {}: {error}",
|
||||
target.provider,
|
||||
target.url
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(delivered)
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_command(platform: &str, title: &str, body: &str) -> Option<(String, Vec<String>)> {
|
||||
match platform {
|
||||
"macos" => Some((
|
||||
@ -218,6 +368,20 @@ fn notification_command(platform: &str, title: &str, body: &str) -> Option<(Stri
|
||||
}
|
||||
}
|
||||
|
||||
fn webhook_payload(target: &WebhookTarget, message: &str) -> serde_json::Value {
|
||||
match target.provider {
|
||||
WebhookProvider::Slack => json!({
|
||||
"text": message,
|
||||
}),
|
||||
WebhookProvider::Discord => json!({
|
||||
"content": message,
|
||||
"allowed_mentions": {
|
||||
"parse": []
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn run_notification_command(program: &str, args: &[String]) -> Result<()> {
|
||||
let status = std::process::Command::new(program)
|
||||
@ -237,6 +401,29 @@ fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn send_webhook_request(target: &WebhookTarget, payload: serde_json::Value) -> Result<()> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(std::time::Duration::from_secs(5))
|
||||
.timeout_read(std::time::Duration::from_secs(5))
|
||||
.build();
|
||||
let response = agent
|
||||
.post(&target.url)
|
||||
.send_json(payload)
|
||||
.with_context(|| format!("POST {}", target.url))?;
|
||||
|
||||
if response.status() >= 200 && response.status() < 300 {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("{} returned {}", target.url, response.status());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn send_webhook_request(_target: &WebhookTarget, _payload: serde_json::Value) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_osascript(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', "")
|
||||
@ -247,10 +434,12 @@ fn sanitize_osascript(value: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
notification_command, DesktopNotificationConfig, DesktopNotifier, NotificationEvent,
|
||||
QuietHoursConfig,
|
||||
notification_command, webhook_payload, CompletionSummaryDelivery,
|
||||
DesktopNotificationConfig, DesktopNotifier, NotificationEvent, QuietHoursConfig,
|
||||
WebhookNotificationConfig, WebhookNotifier, WebhookProvider, WebhookTarget,
|
||||
};
|
||||
use chrono::{Local, TimeZone};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn quiet_hours_support_cross_midnight_ranges() {
|
||||
@ -285,6 +474,7 @@ mod tests {
|
||||
|
||||
assert!(!config.allows(NotificationEvent::SessionCompleted, now));
|
||||
assert!(config.allows(NotificationEvent::BudgetAlert, now));
|
||||
assert!(!config.allows(NotificationEvent::SessionStarted, now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -329,4 +519,117 @@ mod tests {
|
||||
assert_eq!(args[2], "ECC 2.0: Approval needed");
|
||||
assert_eq!(args[3], "worker-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_notifications_require_enabled_targets_and_event() {
|
||||
let mut config = WebhookNotificationConfig::default();
|
||||
assert!(!config.allows(NotificationEvent::SessionCompleted));
|
||||
|
||||
config.enabled = true;
|
||||
config.targets = vec![WebhookTarget {
|
||||
provider: WebhookProvider::Slack,
|
||||
url: "https://hooks.slack.test/services/abc".to_string(),
|
||||
}];
|
||||
|
||||
assert!(config.allows(NotificationEvent::SessionCompleted));
|
||||
assert!(config.allows(NotificationEvent::SessionStarted));
|
||||
assert!(!config.allows(NotificationEvent::ApprovalRequest));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_sanitization_filters_invalid_urls() {
|
||||
let config = WebhookNotificationConfig {
|
||||
enabled: true,
|
||||
targets: vec![
|
||||
WebhookTarget {
|
||||
provider: WebhookProvider::Slack,
|
||||
url: "https://hooks.slack.test/services/abc".to_string(),
|
||||
},
|
||||
WebhookTarget {
|
||||
provider: WebhookProvider::Discord,
|
||||
url: "ftp://discord.invalid".to_string(),
|
||||
},
|
||||
],
|
||||
..WebhookNotificationConfig::default()
|
||||
}
|
||||
.sanitized();
|
||||
|
||||
assert_eq!(config.targets.len(), 1);
|
||||
assert_eq!(config.targets[0].provider, WebhookProvider::Slack);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_webhook_payload_uses_text() {
|
||||
let payload = webhook_payload(
|
||||
&WebhookTarget {
|
||||
provider: WebhookProvider::Slack,
|
||||
url: "https://hooks.slack.test/services/abc".to_string(),
|
||||
},
|
||||
"*ECC 2.0* hello",
|
||||
);
|
||||
|
||||
assert_eq!(payload, json!({ "text": "*ECC 2.0* hello" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discord_webhook_payload_disables_mentions() {
|
||||
let payload = webhook_payload(
|
||||
&WebhookTarget {
|
||||
provider: WebhookProvider::Discord,
|
||||
url: "https://discord.test/api/webhooks/123".to_string(),
|
||||
},
|
||||
"```text\nsummary\n```",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"content": "```text\nsummary\n```",
|
||||
"allowed_mentions": { "parse": [] }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_notifier_sends_to_each_target() {
|
||||
let notifier = WebhookNotifier::new(WebhookNotificationConfig {
|
||||
enabled: true,
|
||||
targets: vec![
|
||||
WebhookTarget {
|
||||
provider: WebhookProvider::Slack,
|
||||
url: "https://hooks.slack.test/services/abc".to_string(),
|
||||
},
|
||||
WebhookTarget {
|
||||
provider: WebhookProvider::Discord,
|
||||
url: "https://discord.test/api/webhooks/123".to_string(),
|
||||
},
|
||||
],
|
||||
..WebhookNotificationConfig::default()
|
||||
});
|
||||
let mut sent = Vec::new();
|
||||
|
||||
let delivered = notifier
|
||||
.try_notify_with(
|
||||
NotificationEvent::SessionCompleted,
|
||||
"payload text",
|
||||
|target, payload| {
|
||||
sent.push((target.provider, payload));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(delivered);
|
||||
assert_eq!(sent.len(), 2);
|
||||
assert_eq!(sent[0].0, WebhookProvider::Slack);
|
||||
assert_eq!(sent[1].0, WebhookProvider::Discord);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_summary_delivery_defaults_to_desktop() {
|
||||
assert_eq!(
|
||||
CompletionSummaryDelivery::default(),
|
||||
CompletionSummaryDelivery::Desktop
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2246,6 +2246,7 @@ mod tests {
|
||||
auto_create_worktrees: true,
|
||||
auto_merge_ready_worktrees: false,
|
||||
desktop_notifications: crate::notifications::DesktopNotificationConfig::default(),
|
||||
webhook_notifications: crate::notifications::WebhookNotificationConfig::default(),
|
||||
completion_summary_notifications:
|
||||
crate::notifications::CompletionSummaryConfig::default(),
|
||||
cost_budget_usd: 10.0,
|
||||
|
||||
@ -15,7 +15,7 @@ use tokio::sync::broadcast;
|
||||
use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter};
|
||||
use crate::comms;
|
||||
use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme};
|
||||
use crate::notifications::{DesktopNotifier, NotificationEvent};
|
||||
use crate::notifications::{DesktopNotifier, NotificationEvent, WebhookNotifier};
|
||||
use crate::observability::ToolLogEntry;
|
||||
use crate::session::manager;
|
||||
use crate::session::output::{
|
||||
@ -81,6 +81,7 @@ pub struct Dashboard {
|
||||
output_store: SessionOutputStore,
|
||||
output_rx: broadcast::Receiver<OutputEvent>,
|
||||
notifier: DesktopNotifier,
|
||||
webhook_notifier: WebhookNotifier,
|
||||
sessions: Vec<Session>,
|
||||
session_output_cache: HashMap<String, Vec<OutputLine>>,
|
||||
unread_message_counts: HashMap<String, usize>,
|
||||
@ -456,6 +457,7 @@ impl Dashboard {
|
||||
.map(|message| message.id);
|
||||
let output_rx = output_store.subscribe();
|
||||
let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone());
|
||||
let webhook_notifier = WebhookNotifier::new(cfg.webhook_notifications.clone());
|
||||
let mut session_table_state = TableState::default();
|
||||
if !sessions.is_empty() {
|
||||
session_table_state.select(Some(0));
|
||||
@ -467,6 +469,7 @@ impl Dashboard {
|
||||
output_store,
|
||||
output_rx,
|
||||
notifier,
|
||||
webhook_notifier,
|
||||
sessions,
|
||||
session_output_cache: HashMap::new(),
|
||||
unread_message_counts: HashMap::new(),
|
||||
@ -3649,21 +3652,40 @@ impl Dashboard {
|
||||
"ECC 2.0: Budget alert",
|
||||
&format!("{summary_suffix} | tokens {token_budget} | cost {cost_budget}"),
|
||||
);
|
||||
self.notify_webhook(
|
||||
NotificationEvent::BudgetAlert,
|
||||
&budget_alert_webhook_body(
|
||||
&summary_suffix,
|
||||
&token_budget,
|
||||
&cost_budget,
|
||||
self.active_session_count(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn sync_session_state_notifications(&mut self) {
|
||||
let mut next_states = HashMap::new();
|
||||
let mut completion_summaries = Vec::new();
|
||||
let mut failed_notifications = Vec::new();
|
||||
let mut started_webhooks = Vec::new();
|
||||
let mut completion_webhooks = Vec::new();
|
||||
let mut failed_webhooks = Vec::new();
|
||||
|
||||
for session in &self.sessions {
|
||||
let previous_state = self.last_session_states.get(&session.id);
|
||||
if let Some(previous_state) = previous_state {
|
||||
if previous_state != &session.state {
|
||||
match session.state {
|
||||
SessionState::Running => {
|
||||
started_webhooks.push(session_started_webhook_body(
|
||||
session,
|
||||
session_compare_url(session).as_deref(),
|
||||
));
|
||||
}
|
||||
SessionState::Completed => {
|
||||
let summary = self.build_completion_summary(session);
|
||||
if self.cfg.completion_summary_notifications.enabled {
|
||||
completion_summaries.push(self.build_completion_summary(session));
|
||||
completion_summaries.push(summary.clone());
|
||||
} else if self.cfg.desktop_notifications.session_completed {
|
||||
self.notify_desktop(
|
||||
NotificationEvent::SessionCompleted,
|
||||
@ -3675,8 +3697,14 @@ impl Dashboard {
|
||||
),
|
||||
);
|
||||
}
|
||||
completion_webhooks.push(completion_summary_webhook_body(
|
||||
&summary,
|
||||
session,
|
||||
session_compare_url(session).as_deref(),
|
||||
));
|
||||
}
|
||||
SessionState::Failed => {
|
||||
let summary = self.build_completion_summary(session);
|
||||
failed_notifications.push((
|
||||
"ECC 2.0: Session failed".to_string(),
|
||||
format!(
|
||||
@ -3685,10 +3713,20 @@ impl Dashboard {
|
||||
truncate_for_dashboard(&session.task, 96)
|
||||
),
|
||||
));
|
||||
failed_webhooks.push(completion_summary_webhook_body(
|
||||
&summary,
|
||||
session,
|
||||
session_compare_url(session).as_deref(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else if session.state == SessionState::Running {
|
||||
started_webhooks.push(session_started_webhook_body(
|
||||
session,
|
||||
session_compare_url(session).as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
next_states.insert(session.id.clone(), session.state.clone());
|
||||
@ -3698,12 +3736,24 @@ impl Dashboard {
|
||||
self.deliver_completion_summary(summary);
|
||||
}
|
||||
|
||||
for body in started_webhooks {
|
||||
self.notify_webhook(NotificationEvent::SessionStarted, &body);
|
||||
}
|
||||
|
||||
if self.cfg.desktop_notifications.session_failed {
|
||||
for (title, body) in failed_notifications {
|
||||
self.notify_desktop(NotificationEvent::SessionFailed, &title, &body);
|
||||
}
|
||||
}
|
||||
|
||||
for body in completion_webhooks {
|
||||
self.notify_webhook(NotificationEvent::SessionCompleted, &body);
|
||||
}
|
||||
|
||||
for body in failed_webhooks {
|
||||
self.notify_webhook(NotificationEvent::SessionFailed, &body);
|
||||
}
|
||||
|
||||
self.last_session_states = next_states;
|
||||
}
|
||||
|
||||
@ -3740,6 +3790,10 @@ impl Dashboard {
|
||||
preview
|
||||
),
|
||||
);
|
||||
self.notify_webhook(
|
||||
NotificationEvent::ApprovalRequest,
|
||||
&approval_request_webhook_body(&message, &preview),
|
||||
);
|
||||
}
|
||||
|
||||
fn deliver_completion_summary(&mut self, summary: SessionCompletionSummary) {
|
||||
@ -3830,6 +3884,10 @@ impl Dashboard {
|
||||
let _ = self.notifier.notify(event, title, body);
|
||||
}
|
||||
|
||||
fn notify_webhook(&self, event: NotificationEvent, body: &str) {
|
||||
let _ = self.webhook_notifier.notify(event, body);
|
||||
}
|
||||
|
||||
fn sync_selection(&mut self) {
|
||||
if self.sessions.is_empty() {
|
||||
self.selected_session = 0;
|
||||
@ -7263,6 +7321,129 @@ fn summarize_completion_warnings(
|
||||
warnings
|
||||
}
|
||||
|
||||
fn session_started_webhook_body(session: &Session, compare_url: Option<&str>) -> String {
|
||||
let mut lines = vec![
|
||||
"*ECC 2.0: Session started*".to_string(),
|
||||
format!(
|
||||
"`{}` {}",
|
||||
format_session_id(&session.id),
|
||||
truncate_for_dashboard(&session.task, 96)
|
||||
),
|
||||
format!(
|
||||
"Project `{}` | Group `{}` | Agent `{}`",
|
||||
session.project, session.task_group, session.agent_type
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(worktree) = session.worktree.as_ref() {
|
||||
lines.push(format!(
|
||||
"```text\nbranch: {}\nbase: {}\nworktree: {}\n```",
|
||||
worktree.branch,
|
||||
worktree.base_branch,
|
||||
worktree.path.display()
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(compare_url) = compare_url {
|
||||
lines.push(format!("PR / compare: {compare_url}"));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn completion_summary_webhook_body(
|
||||
summary: &SessionCompletionSummary,
|
||||
session: &Session,
|
||||
compare_url: Option<&str>,
|
||||
) -> String {
|
||||
let mut lines = vec![
|
||||
format!("*{}*", summary.title()),
|
||||
format!(
|
||||
"`{}` {}",
|
||||
format_session_id(&summary.session_id),
|
||||
truncate_for_dashboard(&summary.task, 96)
|
||||
),
|
||||
format!(
|
||||
"Project `{}` | Group `{}` | State `{}`",
|
||||
session.project, session.task_group, session.state
|
||||
),
|
||||
format!(
|
||||
"Duration `{}` | Files `{}` | Tokens `{}` | Cost `{}`",
|
||||
format_duration(summary.duration_secs),
|
||||
summary.files_changed,
|
||||
format_token_count(summary.tokens_used),
|
||||
format_currency(summary.cost_usd)
|
||||
),
|
||||
if summary.tests_run > 0 {
|
||||
format!(
|
||||
"Tests `{}` run / `{}` passed",
|
||||
summary.tests_run, summary.tests_passed
|
||||
)
|
||||
} else {
|
||||
"Tests `not detected`".to_string()
|
||||
},
|
||||
];
|
||||
|
||||
if !summary.recent_files.is_empty() {
|
||||
lines.push(markdown_code_block("Recent files", &summary.recent_files));
|
||||
}
|
||||
|
||||
if !summary.key_decisions.is_empty() {
|
||||
lines.push(markdown_code_block("Key decisions", &summary.key_decisions));
|
||||
}
|
||||
|
||||
if !summary.warnings.is_empty() {
|
||||
lines.push(markdown_code_block("Warnings", &summary.warnings));
|
||||
}
|
||||
|
||||
if let Some(compare_url) = compare_url {
|
||||
lines.push(format!("PR / compare: {compare_url}"));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn budget_alert_webhook_body(
|
||||
summary_suffix: &str,
|
||||
token_budget: &str,
|
||||
cost_budget: &str,
|
||||
active_sessions: usize,
|
||||
) -> String {
|
||||
[
|
||||
"*ECC 2.0: Budget alert*".to_string(),
|
||||
summary_suffix.to_string(),
|
||||
format!("Tokens `{token_budget}`"),
|
||||
format!("Cost `{cost_budget}`"),
|
||||
format!("Active sessions `{active_sessions}`"),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn approval_request_webhook_body(message: &SessionMessage, preview: &str) -> String {
|
||||
[
|
||||
"*ECC 2.0: Approval needed*".to_string(),
|
||||
format!(
|
||||
"To `{}` from `{}`",
|
||||
format_session_id(&message.to_session),
|
||||
format_session_id(&message.from_session)
|
||||
),
|
||||
format!("Type `{}`", message.msg_type),
|
||||
markdown_code_block("Request", &[preview.to_string()]),
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn markdown_code_block(label: &str, lines: &[String]) -> String {
|
||||
format!("{label}\n```text\n{}\n```", lines.join("\n"))
|
||||
}
|
||||
|
||||
fn session_compare_url(session: &Session) -> Option<String> {
|
||||
session
|
||||
.worktree
|
||||
.as_ref()
|
||||
.and_then(|worktree| worktree::github_compare_url(worktree).ok().flatten())
|
||||
}
|
||||
|
||||
fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str {
|
||||
match action {
|
||||
crate::session::FileActivityAction::Read => "read",
|
||||
@ -11838,6 +12019,7 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
let selected_session = selected_session.min(sessions.len().saturating_sub(1));
|
||||
let cfg = Config::default();
|
||||
let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone());
|
||||
let webhook_notifier = WebhookNotifier::new(cfg.webhook_notifications.clone());
|
||||
let last_session_states = sessions
|
||||
.iter()
|
||||
.map(|session| (session.id.clone(), session.state.clone()))
|
||||
@ -11856,6 +12038,7 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
output_store,
|
||||
output_rx,
|
||||
notifier,
|
||||
webhook_notifier,
|
||||
sessions,
|
||||
session_output_cache: HashMap::new(),
|
||||
unread_message_counts: HashMap::new(),
|
||||
@ -11937,6 +12120,7 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
auto_create_worktrees: true,
|
||||
auto_merge_ready_worktrees: false,
|
||||
desktop_notifications: crate::notifications::DesktopNotificationConfig::default(),
|
||||
webhook_notifications: crate::notifications::WebhookNotificationConfig::default(),
|
||||
completion_summary_notifications:
|
||||
crate::notifications::CompletionSummaryConfig::default(),
|
||||
cost_budget_usd: 10.0,
|
||||
|
||||
@ -373,6 +373,20 @@ pub fn create_draft_pr(worktree: &WorktreeInfo, title: &str, body: &str) -> Resu
|
||||
create_draft_pr_with_gh(worktree, title, body, Path::new("gh"))
|
||||
}
|
||||
|
||||
pub fn github_compare_url(worktree: &WorktreeInfo) -> Result<Option<String>> {
|
||||
let repo_root = base_checkout_path(worktree)?;
|
||||
let origin = git_remote_origin_url(&repo_root)?;
|
||||
let Some(repo_url) = github_repo_web_url(&origin) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(format!(
|
||||
"{repo_url}/compare/{}...{}?expand=1",
|
||||
percent_encode_git_ref(&worktree.base_branch),
|
||||
percent_encode_git_ref(&worktree.branch)
|
||||
)))
|
||||
}
|
||||
|
||||
fn create_draft_pr_with_gh(
|
||||
worktree: &WorktreeInfo,
|
||||
title: &str,
|
||||
@ -418,6 +432,67 @@ fn create_draft_pr_with_gh(
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn git_remote_origin_url(repo_root: &Path) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_root)
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.output()
|
||||
.context("Failed to resolve git origin remote")?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("git remote get-url origin failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn github_repo_web_url(origin: &str) -> Option<String> {
|
||||
let trimmed = origin.trim().trim_end_matches(".git");
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("git@") {
|
||||
let (host, path) = rest.split_once(':')?;
|
||||
return Some(format!("https://{host}/{}", path.trim_start_matches('/')));
|
||||
}
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("ssh://") {
|
||||
return parse_httpish_remote(rest);
|
||||
}
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("https://") {
|
||||
return parse_httpish_remote(rest);
|
||||
}
|
||||
|
||||
if let Some(rest) = trimmed.strip_prefix("http://") {
|
||||
return parse_httpish_remote(rest);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_httpish_remote(rest: &str) -> Option<String> {
|
||||
let without_user = rest.strip_prefix("git@").unwrap_or(rest);
|
||||
let (host, path) = without_user.split_once('/')?;
|
||||
Some(format!("https://{host}/{}", path.trim_start_matches('/')))
|
||||
}
|
||||
|
||||
fn percent_encode_git_ref(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
let ch = byte as char;
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') {
|
||||
encoded.push(ch);
|
||||
} else {
|
||||
encoded.push('%');
|
||||
encoded.push_str(&format!("{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result<Vec<String>> {
|
||||
let mut preview = Vec::new();
|
||||
let base_ref = format!("{}...HEAD", worktree.base_branch);
|
||||
@ -1730,6 +1805,47 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_compare_url_uses_origin_remote_and_encodes_refs() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("ecc2-compare-url-{}", Uuid::new_v4()));
|
||||
let repo = init_repo(&root)?;
|
||||
run_git(
|
||||
&repo,
|
||||
&["remote", "add", "origin", "git@github.com:example/ecc.git"],
|
||||
)?;
|
||||
|
||||
let worktree = WorktreeInfo {
|
||||
path: repo.clone(),
|
||||
branch: "ecc/worker-123".to_string(),
|
||||
base_branch: "main".to_string(),
|
||||
};
|
||||
|
||||
let url = github_compare_url(&worktree)?.expect("compare url");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://github.com/example/ecc/compare/main...ecc%2Fworker-123?expand=1"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_repo_web_url_supports_multiple_remote_formats() {
|
||||
assert_eq!(
|
||||
github_repo_web_url("git@github.com:example/ecc.git").as_deref(),
|
||||
Some("https://github.com/example/ecc")
|
||||
);
|
||||
assert_eq!(
|
||||
github_repo_web_url("https://github.example.com/org/repo.git").as_deref(),
|
||||
Some("https://github.example.com/org/repo")
|
||||
);
|
||||
assert_eq!(
|
||||
github_repo_web_url("ssh://git@github.example.com/org/repo.git").as_deref(),
|
||||
Some("https://github.example.com/org/repo")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_for_session_links_shared_node_modules_cache() -> Result<()> {
|
||||
let root =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user