Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8fc0827
feat: add OpenCode serve polling source
Apr 9, 2026
a433fed
feat: add opencode event renderer with emoji formatting
Apr 9, 2026
b870a44
fix: multiline opencode event rendering with kind label + session ID
Apr 9, 2026
6e54f29
fix: warmup phase — skip events for pre-existing sessions on startup
Apr 9, 2026
2e90209
fix: prevent duplicate idle alerts — only clear idle flag on actual n…
Apr 9, 2026
3a1e7e0
feat: add openclaw sink for GitHub issue → agent session pipeline
Apr 14, 2026
61e448e
fix: use /hooks/wake endpoint for openclaw sink
Apr 14, 2026
34dc229
feat: rich GitHub issue format with emoji, links, labels + handle clo…
Apr 14, 2026
4fd38e0
feat: route PR events to /hooks/pr-review for agent-action review wor…
Apr 14, 2026
49bef04
fix: PR review data in sink text, deliver:false, reopened actions
Apr 14, 2026
90903bd
feat: auto-route issue-opened to /hooks/issue-triage for worker spawn
Apr 14, 2026
7593c2a
fix: add path field to workspace payload + STATUS tag support in stat…
Apr 22, 2026
7f5b1c7
feat(sink): add IyenSystemSink for delegating events to IYENsystem
changeroa Apr 27, 2026
9e1de09
ci: silence pre-existing clippy warnings and apply rustfmt
changeroa Apr 27, 2026
eab1dbe
Improve GitHub event Discord formatting
IYENTeam Apr 27, 2026
67ecfdc
fix: suppress initial GitHub polling events
Apr 27, 2026
e4e9748
fix: suppress newly discovered terminal CI runs
Apr 27, 2026
caa5321
feat(events): add github_issues_labeled IncomingEvent constructor
changeroa Apr 28, 2026
9f055bf
feat(source/github): poll labels and emit github.issues-labeled
changeroa Apr 28, 2026
4f5d4e8
docs(readme): document github.issues-labeled event and iyensystem rou…
changeroa Apr 28, 2026
7f947ab
Merge pull request #4 from IYENTeam/feat/labels-polling
IYENTeam Apr 28, 2026
0f63d56
feat(events+source/github): poll PR reviews and emit github.pr-review…
IYENTeam Apr 28, 2026
be6d1f7
feat(sink): add hermes sink for OpenAI-compatible /v1/runs delivery
changeroa May 6, 2026
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
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,18 +393,21 @@ Verification:

Input:
- GitHub webhook `pull_request.*`
- built-in PR monitor state changes
- built-in PR monitor state changes (status + reviews)
- CLI thin client `clawhip github pr-status-changed ...`

Behavior:
- emit `github.pr-status-changed`
- emit `github.pr-review-submitted` when `emit_pr_reviews = true` on a monitored repo (per-PR polling of `/repos/{owner}/{repo}/pulls/{n}/reviews`, baseline-then-emit)
- review payload carries `payload.review.state` (one of `approved`, `changes_requested`, `commented`, `dismissed`), `payload.review.body`, and `payload.sender.login`
- route via `github.*`
- apply repo filter
- prepend route mention if configured

Verification:
- open real PR
- merge / close PR
- submit a real review on a PR with `emit_pr_reviews = true`
- confirm final Discord message body in target channel

### 6. Git commit preset family
Expand Down Expand Up @@ -592,7 +595,9 @@ Verification:
- `github.issue-opened`
- `github.issue-commented`
- `github.issue-closed`
- `github.issues-labeled`
- `github.pr-status-changed`
- `github.pr-review-submitted`

### Git family
- `git.commit`
Expand Down Expand Up @@ -655,6 +660,35 @@ sink = "discord"
channel = "1480171113253175356"
format = "alert"
allow_dynamic_tokens = false

# Forward labeled-issue events to IYENsystem so its `iyen:auto-fix` /
# `iyen:declined` / `iyen:review` label triggers can fire (see
# [providers.iyensystem]). `github.issues-labeled` carries
# `payload.sender.login` and `payload.label.name` so IYENsystem's
# SafetyPolicy gate can validate the (actor, label) pair before enqueuing
# work. `iyen:review` on a PR (clawhip emits `issues-labeled` for both
# issues and PRs — IYENsystem's PrReviewWorkflow gates on
# `payload.issue.pull_request` to distinguish) hands the PR to
# IYENsystem's review lane.
[[routes]]
event = "github.issues-labeled"
filter = { repo = "IYENTeam/example-repo" }
sink = "iyensystem"
allow_dynamic_tokens = false

# Forward submitted PR reviews to IYENsystem so its ReviewResultHandler
# can decide on merges. `approved` reviews coming from outside the
# system (an OpenClaw / human reviewer, not IYENsystem's own
# reviewer-bot) hit the auto_merge_allowlist + human_approved gate and
# may trigger `merge_pr` + linked-issue close. `changes_requested`
# reviews log only — IYENsystem doesn't auto-retry under the new
# label-driven design (review submissions are stateless).
# Requires the monitored repo entry to set `emit_pr_reviews = true`.
[[routes]]
event = "github.pr-review-submitted"
filter = { repo = "IYENTeam/example-repo" }
sink = "iyensystem"
allow_dynamic_tokens = false
```

Resolution rules:
Expand Down
127 changes: 124 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ use std::time::Duration;

use serde::{Deserialize, Serialize};

use crate::Result;
use crate::events::MessageFormat;
use crate::source::workspace::{default_workspace_debounce_ms, default_workspace_watch_dirs};
use crate::Result;

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppConfig {
Expand Down Expand Up @@ -39,6 +39,12 @@ pub struct ProvidersConfig {
pub discord: DiscordConfig,
#[serde(default)]
pub slack: SlackConfig,
#[serde(default)]
pub openclaw: OpenClawConfig,
#[serde(default)]
pub iyensystem: IyenSystemConfig,
#[serde(default)]
pub hermes: HermesConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
Expand All @@ -52,6 +58,39 @@ pub struct DiscordConfig {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SlackConfig {}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenClawConfig {
pub gateway_url: Option<String>,
pub gateway_token: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IyenSystemConfig {
pub url: Option<String>,
pub auth_token: Option<String>,
}

/// Configuration for routing events to a Hermes Agent gateway as a
/// decision authority. Mirrors [`OpenClawConfig`]: when both
/// `base_url` and `auth_token` are set, the dispatcher registers a
/// `hermes` sink and routes with `sink = "hermes"` become eligible.
///
/// Optional knobs:
/// - `instructions`: override the IYEN-domain system prompt baked
/// into [`crate::sink::HermesSink`]. Used when an operator ships a
/// custom Hermes skill.
/// - `model`: pin a specific model id; otherwise Hermes uses its
/// configured default.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HermesConfig {
pub base_url: Option<String>,
pub auth_token: Option<String>,
#[serde(default)]
pub instructions: Option<String>,
#[serde(default)]
pub model: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonConfig {
#[serde(default = "default_bind_host")]
Expand All @@ -68,9 +107,31 @@ impl DiscordConfig {
}
}

impl OpenClawConfig {
pub fn is_configured(&self) -> bool {
crate::sink::OpenClawSink::is_configured(&self.gateway_url, &self.gateway_token)
}
}

impl IyenSystemConfig {
pub fn is_configured(&self) -> bool {
crate::sink::IyenSystemSink::is_configured(&self.url, &self.auth_token)
}
}

impl HermesConfig {
pub fn is_configured(&self) -> bool {
crate::sink::HermesSink::is_configured(&self.base_url, &self.auth_token)
}
}

impl ProvidersConfig {
fn is_empty(&self) -> bool {
self.discord.is_empty() && self.slack.is_empty()
self.discord.is_empty()
&& self.slack.is_empty()
&& !self.openclaw.is_configured()
&& !self.iyensystem.is_configured()
&& !self.hermes.is_configured()
}
}

Expand Down Expand Up @@ -210,6 +271,8 @@ pub struct MonitorConfig {
pub tmux: TmuxMonitorConfig,
#[serde(default)]
pub workspace: Vec<WorkspaceMonitor>,
#[serde(default)]
pub opencode: OpenCodeMonitorConfig,
}

impl Default for MonitorConfig {
Expand All @@ -221,6 +284,7 @@ impl Default for MonitorConfig {
git: GitMonitorConfig::default(),
tmux: TmuxMonitorConfig::default(),
workspace: Vec::new(),
opencode: OpenCodeMonitorConfig::default(),
}
}
}
Expand Down Expand Up @@ -252,6 +316,8 @@ pub struct GitRepoMonitor {
pub emit_issue_opened: bool,
#[serde(default)]
pub emit_pr_status: bool,
#[serde(default)]
pub emit_pr_reviews: bool,
pub channel: Option<String>,
pub mention: Option<String>,
pub format: Option<MessageFormat>,
Expand All @@ -268,6 +334,7 @@ impl Default for GitRepoMonitor {
emit_branch_changes: true,
emit_issue_opened: true,
emit_pr_status: false,
emit_pr_reviews: false,
channel: None,
mention: None,
format: None,
Expand Down Expand Up @@ -336,6 +403,25 @@ impl Default for WorkspaceMonitor {
}
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenCodeMonitorConfig {
pub url: Option<String>,
#[serde(default = "default_opencode_poll_interval")]
pub poll_interval_secs: u64,
#[serde(default = "default_opencode_idle_threshold")]
pub idle_threshold_secs: u64,
pub channel: Option<String>,
pub mention: Option<String>,
pub format: Option<MessageFormat>,
}

fn default_opencode_poll_interval() -> u64 {
10
}
fn default_opencode_idle_threshold() -> u64 {
600
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CronConfig {
#[serde(default = "default_cron_poll_interval_secs")]
Expand Down Expand Up @@ -601,7 +687,10 @@ impl AppConfig {
format!("route #{} ({}) must set a sink", index + 1, route.event).into(),
);
}
if !matches!(sink, "discord" | "slack") {
if !matches!(
sink,
"discord" | "slack" | "openclaw" | "iyensystem" | "hermes"
) {
return Err(format!(
"route #{} ({}) uses unsupported sink '{}'",
index + 1,
Expand Down Expand Up @@ -650,6 +739,36 @@ impl AppConfig {
.into());
}
}
"openclaw" => {
if !self.providers.openclaw.is_configured() {
return Err(format!(
"route #{} ({}) uses openclaw sink but [providers.openclaw] is not configured",
index + 1,
route.event
)
.into());
}
}
"iyensystem" => {
if !self.providers.iyensystem.is_configured() {
return Err(format!(
"route #{} ({}) uses iyensystem sink but [providers.iyensystem] is not configured",
index + 1,
route.event
)
.into());
}
}
"hermes" => {
if !self.providers.hermes.is_configured() {
return Err(format!(
"route #{} ({}) uses hermes sink but [providers.hermes] is not configured",
index + 1,
route.event
)
.into());
}
}
_ => unreachable!(),
}
}
Expand Down Expand Up @@ -1082,6 +1201,7 @@ mod tests {
legacy_default_channel: None,
},
slack: SlackConfig::default(),
..ProvidersConfig::default()
},
routes: vec![RouteRule {
event: "tmux.keyword".into(),
Expand Down Expand Up @@ -1319,6 +1439,7 @@ message = " ping "
legacy_default_channel: None,
},
slack: SlackConfig::default(),
..ProvidersConfig::default()
},
cron: CronConfig {
poll_interval_secs: 30,
Expand Down
Loading
Loading